Branded ID Types
VSId<K, T> and stringToId() for boundary casting.
#The problem
In TypeScript, ContactId and UserId are both just string. Nothing stops you from passing a UserId where a ContactId is expected — they are structurally identical, so the compiler won't catch the mistake.
// Without branded IDs — this compiles fine, but it is wrong
function deleteContact(id: string): Promise<void> { /* … */ }
const userId = '507f1f77bcf86cd799439011';
deleteContact(userId); // ✅ TypeScript is happy — this is a bug#VSId — the fix
VSId attaches an invisible compile-time "brand" to a type. Two branded IDs with different brands are incompatible even though both are plain strings at runtime.
type VSId<K, T> = K & { __brand: T };
// K = the underlying primitive (string, number, …)
// T = the brand tag — a unique string literal per entity#Declaring your ID types
The convention is to put ID types in the entity's type namespace:
// contacts/contacts.types.ts
import type { VSId } from '@void-snippets/core';
export namespace Contact {
export type Id = VSId<string, 'Contact'>;
}
export namespace User {
export type Id = VSId<string, 'User'>;
}#What you gain
function deleteContact(id: Contact.Id): Promise<void> { /* … */ }
const contactId: Contact.Id = stringToId<Contact.Id>('abc-123');
const userId: User.Id = stringToId<User.Id>('xyz-456');
deleteContact(contactId); // ✅ correct
deleteContact(userId); // ❌ TypeScript error — 'User' brand ≠ 'Contact' brand
deleteContact('raw-str'); // ❌ TypeScript error — plain string is not Contact.IdRuntime behaviour is unchanged. Branded IDs are still plain strings — no wrapping, no overhead. The brand only lives in the type system.
#stringToId(id: string): T
The safe way to cross the boundary between raw strings (URL params, API responses, localStorage) and typed IDs. Call it once at the edge of your system.
| Parameter | Type | Description |
|---|---|---|
id | string | Any raw string — URL param, API field, stored value |
Returns T — the branded ID type you specify.
import { stringToId } from '@void-snippets/core';
import type { Contact } from './contacts.types';
// From a React Router route param (always a plain string)
const { contactId: raw } = useParams();
const contactId = stringToId<Contact.Id>(raw!);
// From an API response body
const createdContact = await ContactsApis.create(payload);
const id = createdContact._id; // already Contact.Id — the service typed it for you