|
Here’s an easy way to get branded string types in TypeScript. This allows the
compiler to enforce minimal type safety on strings with an identity such as a
User ID or Post ID.
export type Brand<K, T extends string> = K & { [P in T]: never };
This can be helpful when you have multiple kinds of IDs that are all strings at
runtime, and you want to avoid accidentally passing them in the wrong place.
It also lets types in consuming code offer some documentation of interfaces, for
example:
function getPostById(id: PostId): Post {
// ...
}
When interfaces involve multiple different kinds of ID, this can make it easier
to follow.
You need to explicitly cast to the branded type at the boundary where you create
the value, but that in itself can be helpful in catching mistakes and making
intentions explicit.
For input, the branded type casting can be automated with a validation library
such as Zod, for example:
import * as z from "zod";
import { PostId, TagId } from "../util/brand.type";
const TagPostValidator = z.object({
postId: z.uuid().transform((s) => s as PostId),
tagId: z.uuid().transform((s) => s as TagId),
});
// ...
const { postId, tagId } = TagPostValidator.parse({
postId: "0c1b665e-059b-4e27-bf45-c960b535bcdb",
tagId: "308ffcb7-f027-4b4e-b956-bfdce4b70567"
});
Now the postId and tagId variables are typed as PostId and TagId
respectively, as well as being validated as UUIDs.
A quick breakdown of K & { [P in T]: never }:
K & ... is an intersection type: you get all of K, plus some extra typing.
{ [P in T]: never } is a mapped type that adds a property whose key is the
literal type T (e.g. "UserId"). The value is never, meaning you can’t
ever provide a value for this property.
So at runtime the value is still just a string, but at compile time the type
carries an extra “tag” that makes Brand<string, "UserId"> incompatible with
Brand<string, "PostId">.
View post:
TypeScript branded string type utility for type safety
|