Loading Stories...
Loading Stories...
type Brand<BaseType, Brand> = BaseType & { readonly __brand__: Brand };
type FooId = Brand<string, 'FooId'>;
function fooBar(asdf: FooId | 'foobar'): void { }
fooBar will only accept the literal string 'foobar' or a true FooId, but not any arbitrary string. FooId would then come from a function that validates strings as FooIds, or some other part of the app that is an authoritative source for them. Brands extend their BaseType so they can be used anywhere their BaseType is used, but not the inverse type Brand<BaseType, Brand extends symbol> = BaseType & { readonly __brand__: Brand };
const FooIdBrand = Symbol('FooId');
type FooId = Brand<string, typeof FooIdBrand>;
function fooBar(asdf: FooId | 'foobar'): void { }
Using a private shared symbol your authoritative validation/sources can share your brand symbol and no one else can create one without using your validation. Private symbol brands in this way become the closest Typescript gets to "nominal types".Also available for a whole bunch of other languages: https://github.com/jetpack-io/typeid
UUIDv7 is UUIDv4-compatible (i.e. you can put a v7 UUID anywhere a v4 UUID would go, like in Postgres's UUID datatype) and is time-series sortable, so you don't lose that nice lil' benefit of auto-incrementing IDs.
And if you use something like TypeORM to define your entities, you can use a Transformer to save as plain UUIDv7 in the DB (so you can use UUID datatypes, not strings), but deal with them as type-prefixed strings everywhere else:
export const TYPEID_USER = 'user';
export type UserTypeID = TypeID<'user'>;
export type UserTypeString = `user_${string}`;
export class UserIdTransformer implements ValueTransformer {
from(uuid: string): UserTypeID {
return TypeID.fromUUID(TYPEID_USER, uuid);
}
to(tid: UserTypeID): string {
assert.equal(
tid.getType(),
TYPEID_USER,
`Invalid user ID: '${tid.toString()}'.`,
);
return tid.toUUID();
}
}
@Entity()
export class User {
@PrimaryColumn({
type: 'uuid',
primaryKeyConstraintName: 'user_pkey',
transformer: new UserIdTransformer(),
})
id: UserTypeID;
@BeforeInsert()
createNewPrimaryKey() {
this.id = typeid(TYPEID_USER);
}
}
The proliferation of string identifiers is a pet peeve of mine. It’s what I call “stringly typed” code (not my coinage but I use it all the time).
type Target = 'currentNode' | (string & {});
const targets: Target[] = [
'currentNode', // you get autocomplete hints for this!
'somethingElse', // no autocomplete here, but it typechecks
];
type UserId = `usr_${string}`
const user = { id: 'bgy5D4eL' as unknown as UserId }
Casting would just need to be applied wherever the object is generated, retrieving from the database requires casting either way. It could be a footgun though, if someone working on the codebase thought that the prefix is actually there and decided to use it for a runtime check.I wanted to add this to the article, but decided not to, since I think having the prefix at runtime is just as useful - wherever the ID occurs, i.e. in error log the type of the object is always clear. But that or type branding is something that is much easier to apply in an existing system indeed.
Btw. I submitted this on Monday 6 AM EST and now it is visible as submitted 19h ago? I totally did not expect to make it far, let alone /front when it initially did not get any upovtes. I'm curious how it works :)
I considered doing it on a recent project, but it doesn't seem very common so I was reluctant to introduce it.
- Channel IDs always start with C
- User IDs always start with U
However, that's not the case when it comes to Typescript, because literal and union string types are actually checked at compile time. So what is the problem?
Personally I love them and prefer them in all cases. They aren’t enumerable, never get confused for “is this an array or a map by ID” in PHP, can be used safely as keys without some languages (looking at you PHP) returning an array instead of an object (assoc. array) when converting to JSON, don’t need to be converted back to a number after passing through something like a URL/get param, are less likely to have overlap with keys from other types (even more so if you prefix the key with a type identifier), no need to know that last ID used in the DB so you can build your key in app code instead of the DB, and I’m sure I have more things I like about them.
I understand auto-inc can have some performance gains in the DB but I’ve never needed the gains more than I wanted sane (in my mind) ids.
For the longest time I used UUID (v4) and I still do sometimes but lately I’ve liked KSUID since they are sortable by create date (great for things like DynamoDB IMHO).
Say, /view/users/jdoe/foo -- is that foo a resource, or a URL my web framework can use to e.g. fetch data & components SPA style?
With a flat /view/user_jdoe/foo, you don't have that source of confusion.
It was really nice in my Kotlin project because we were dealing with legacy data structures with very confusing names—being able to guarantee that a UserID doesn't accidentally get passed where a UserDataID was expected helped prevent a lot of the bugs that plagued the legacy apps.
Not sure whether I’d do it again or not. It’s hard to say how much benefit that I’m getting out of them.
https://www.typescriptlang.org/play?#code/C4TwDgpgBAkgIlAvFA...
Alternatively, with generics: https://www.typescriptlang.org/play?#code/JYOwLgpgTgZghgYwgA...
I don't think these are better, to be honest. The string types suffice and are easier to exchange with servers and other APIs.
That said, the other benefit to using private symbols like this is that they are also easy to enforce at runtime, because symbol visibility is enforced at runtime (you can't create the same signal by hand somewhere else). It can be as easy as something like:
console.assert(id.__brand__ === FooIdBrand)
(That still won't stop the determined hacker in the console dev tools, if they can see a symbol they can create a reference to it, defense in depth will always be a thing.) declare const isMyID: unique symbol;
export type MyID = string & { [isMyID]: true };
A string belongs to the domain of “all strings” and so the type system and compiler cannot catch something like “authorizeUser(id,…) where the id is in fact “” or a groupid or “null” or “undefined”.
Lots of code does what you describe. But I prefer to use the type system wherever I can.
When converting something to this type, it will fail unless you cast it, but it's a compile-time cast. At runtime, there's no conversion.
This is essentially "lying" to the type checker in order to extend it.
Added benefit, as always when leaning into the type system more, is a reduction in the number of unit tests required. The need to test that `update_user(group_id)` fails (because a non-user ID was passed) simply disappears.
I haven't observed any issues with branded types and infer—is there documentation somewhere about the problem?
For example:
type UserId = `user_${string}`;
type GroupId = `group_${string}`;
const addUserId = (id: UserId) => {
// do something
}
const processId = (id: string) => {
if (id.startsWith('user_') {
// type error here:
addUserId(id);
} else if (otherCondition)
// do other things
}
}
Instead you define a function: const isUserId = (some: string): some is UserId => some.startsWith('user_');
Now you can use it as follows: const processId = (id: string) => {
if (isUserId(id)) {
// no more error:
addUserId(id);
} else if (otherCondition)
// do other things
}
}
[1]: https://www.typescriptlang.org/docs/handbook/2/narrowing.htm... function is<T extends string>(value: string, prefix: T): value is `${typeof prefix}_${string}` {
return value.startsWith(`${prefix}_`)
}
You can now do `is(id, 'user')`.If you do that often you probably want to create separate functions, e.g.:
function isFactory<T extends string>(prefix: T) {
return (value: string) => is(value, prefix)
}
const isUser = isFactory('user')
const isOrder = isFactory('order')
Not too bad.doStuff({userId: foo, itemId: bar});
This allows the order of key/val pairs to move around, making it more robust to mistakes than doStuff: (string,string) => void.