Advanced Typescript: Tagged Types Improved with Type-Level Metadata
This post assumes you know what tagged types are, and how they can make your code more secure and less bug-prone. If not, go read about them first.
From that article, then you may have noticed that a tag is like a binary flag: a type either includes the tag, or it doesn’t.
But, are there use cases for storing other information along with a tag, rather than merely recording the tag’s presence?
First, recall that Tagged
was defined as:
declare const tags: unique symbol;
type Tagged<BaseType, Tag extends PropertyKey> =
BaseType & { [tags]: { [K in Tag]: void } };
The keys in the object type at [tags]
record which tags are present on the final type, but the values in that object type are always void
. This offers an opportunity to replace void
with a type parameter, Metadata
, as in:
type Tagged<BaseType, Tag extends PropertyKey, Metadata = void> =
BaseType & { [tags]: { [K in Tag]: Metadata } };
Now, the user of Tagged
can pass an arbitrary type as Metadata
, and that type will be associated with the tag’s name when it’s stored in [tags]
.
In most cases, this isn’t that useful, because all the available type information about the value being assigned to the tagged type will already be preserved in the BaseType
(i.e., the value’s underlying, runtime type, without the tags).
For example, you might have a function that accepts a string
, validates it as an email, and returns a Tagged<string, 'Email'>
if it’s valid. In that case, all the type information you knew about the argument (i.e., that it was a string
) is preserved in the final Tagged
type.
Even when the type being tagged varies, it’s possible to preserve its untagged type with a type parameter. For example, if Password
values can be strings or numbers, not just strings, one can do:
type Password<T extends string | number> = Tagged<T, 'Password'>;
function validatePassword<T extends string | number>(password: T) {
// if the argument is a string, validate that `password` is over/under
// some required number of characters; if it's a number, validate that
// it's in some range.
// Then...
return password as Password<T>;
}
// The returned tagged/validated passwords preserve the given password's type
validatePassword("Hello") // -> Tagged<string, Password>
validatePassword(42) // -> Tagged<number, Password>
However, Metadata
enables use cases where you know some information about the value being assigned to the tagged type, and this information goes beyond the value’s runtime type (which is already preserved in the BaseType
parameter).
The most common example is with encoding. For example, suppose an object is JSON serialized (say, to pass it between the database and the application). In that case, the value’s runtime type will be a string
, and we might want to tag that string to indicate that it’s a string of JSON. However, it would also be very useful to know what type of value we’ll get back when parsing the string. Metadata enables that.
First, though, let’s see the example without Metadata
:
type Json = Tagged<string, 'JSON'>;
function jsonStringify(it: unknown) {
return JSON.stringify(it) as Json;
}
function jsonParse(it: Json) {
return JSON.parse(it);
}
Here, the Json
type is a string
type with an additional 'JSON'
tag. We leverage this tag to add some type-safety: only strings that have this tag can be passed to jsonParse
, which ensures that we won’t get runtime errors when parsing JSON (assuming that code that creates Json
values, like jsonStringify
, correctly upholds the 'JSON'
tag’s promise, namely: that values tagged with 'JSON'
will be well-formed JSON strings).
What’s missing is a way to know what type of value we’ll get back from jsonParse
, which currently always returns any
. The trick is to use metadata to capture the original type when serializing, which can then be recovered on parse¹:
type JsonOf<OriginalType> = Tagged<string, 'JSON', OriginalType>;
function jsonStringify<T>(it: T) {
return JSON.stringify(it) as JsonOf<T>;
}
function jsonParse<T>(it: JsonOf<T>): T {
return JSON.parse(it) as T;
}
// The full type of x is:
// `string & { [tags]: { JSON: { hello: string } } }`
// I.e., the base runtime type of x is `string`, while the original,
// unstringified value's type is preserved at the JSON tag's key.
const x = jsonStringify({ hello: "world" });
const y = jsonParse(x); // { hello: string } type recovered!
Metadata can also be useful when a tagged value’s runtime type is intentionally obfuscated, to encourage code to treat it as “opaque”, e.g.:
// Imagine a "user id" module exports this UserId type, plus some helper
// functions to work with UserIds. The BaseType of UserId is `unknown`, to
// encourage code that has a UserId to pass it to the helper functions,
// rather than try to inspect it directly. The actual runtime value behind
// this `unknown` type is a { scope, id } object.
export type UserId<T extends string | number> = Tagged<unknown, 'UserId', T>;
export function makeUserId<T extends string | number>(userId: T) {
return { scope: 'default', id: userId } as unknown as UserId<T>;
}
export function getId<T extends string | number>(userId: UserId<T>): T {
return (userId as unknown as { scope: string, id: T }).id;
}
// example...
const userId = makeUserId('johndoe');
const userId2 = makeUserId(42);
const id1 = getId(userId); // type of id1 is string
const id2 = getId(userId2); // type of id2 is number
The key thing shown above is that, even though makeUserId
’s return type is not a subtype of T
— i.e., the returned value is not a string or number, because, instead, it’s an internal, object-based representation of the user id— the original type of the userId
argument can be preserved in the Metadata
type and returned by getId
.
Overall, then, Metadata
is useful in any case where there’s some type information that you know about the value being assigned to the tagged type, beyond its runtime type, and you’d like to preserve that information for later use. The situations for this are somewhat niche, except for encoding and decoding, which comes up in most codebases. Nevertheless, it’s a powerful tool to have in the toolbox when you need it.
[1]: If the original data passed to jsonStringify
contained values/types that don’t roundtrip losslessly through JSON.stringify
and JSON.parse
— i.e., values for which JSON.parse(JSON.stringify(value))
doesn’t give back the original value
, like Date
and RegExp
objects — then it’s not quite right to say that jsonParse
returns the original type passed to jsonStringify
; instead, the type should technically be something like Jsonify<T>
, or the type parameter T
on jsonStringify
should limit the function to only stringifying JSON-compatible values to begin with. I omitted those details above for simplicity.