@void-snippets/core

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.

typescript
// 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.

typescript
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:

typescript
// 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

typescript
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.Id

Runtime 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.

ParameterTypeDescription
idstringAny raw string — URL param, API field, stored value

Returns T — the branded ID type you specify.

typescript
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