Branded types
Tag primitives nominally without a runtime cost.
A "branded" type is a primitive (usually string or number) decorated with a phantom field that the compiler tracks but the runtime ignores. The result: two values can look identical at runtime yet be type-incompatible.
The problem
function deleteUser(userId: string) { /* ... */ }
function deletePost(postId: string) { /* ... */ }
const postId = '42'
deleteUser(postId) // ← compiles. Bug.
string is the same type everywhere. The compiler can't help.
The fix
import type { Brand } from '@gentleduck/ttest/brand'
type UserId = Brand<string, 'UserId'>
type PostId = Brand<string, 'PostId'>
function deleteUser(id: UserId) { /* ... */ }
function deletePost(id: PostId) { /* ... */ }
const post = '42' as PostId
deleteUser(post) // ← Error: Argument of type 'PostId' is not assignable to parameter of type 'UserId'.
Runtime is unchanged — post is still the string '42'.
Constructing brands safely
The cast as PostId lies. Centralize it in a "smart constructor":
import type { Brand } from '@gentleduck/ttest/brand'
type PostId = Brand<string, 'PostId'>
function postId(raw: string): PostId {
if (!/^[a-z0-9]+$/.test(raw)) throw new Error(`invalid post id: ${raw}`)
return raw as PostId
}
Everywhere else, call postId('...') instead of the as cast — the validator runs once, the brand is conferred once, and the rest of the codebase relies on the type.
Stripping a brand
Unbrand recovers the base type:
import type { Brand, Unbrand } from '@gentleduck/ttest/brand'
type UserId = Brand<string, 'UserId'>
type S = Unbrand<UserId> // string
function serialize(id: UserId): Unbrand<UserId> {
return id // base type — no cast needed in the consumer.
}
Brands in the API surface
Brand at the API boundary, not in your hot path:
// Public input boundary
export function createUser(name: string): UserId { /* ... */ }
// Internal helpers can take the raw type — branding everywhere is noise.
function hashId(id: string): string { /* ... */ }
Type-testing brands
import type { AssertTrue } from '@gentleduck/ttest/assert'
import type { Equal } from '@gentleduck/ttest/equality'
import type { Brand, Unbrand, BrandOf } from '@gentleduck/ttest/brand'
type UserId = Brand<string, 'UserId'>
type _ = [
AssertTrue<Equal<Unbrand<UserId>, string>, 'unbrand recovers string'>,
AssertTrue<Equal<BrandOf<UserId>, 'UserId'>, 'brand tag is UserId'>,
]
Note: Equal<UserId, string> is false (and should be). Reach for Unbrand whenever the test is "this is a branded string."
When not to brand
- Throwaway IDs in a script — brand overhead isn't worth it.
- Cross-package boundaries where the consumer can't import the brand.
- High-fan-in primitives like timestamps — you'd brand every
Datefield in the codebase.
Anti-patterns
Branding without a smart constructor
type UserId = Brand<string, 'UserId'>
function fetchUser(id: UserId) { /* ... */ }
fetchUser('u_42' as UserId) // ← `as` cast scattered everywhere
Every as UserId cast is an opportunity to lie. Either centralize the cast in a single userId(raw): UserId constructor that validates, or accept string at the boundary and convert internally — the in-between is a footgun.
Asserting Equal<Branded, Base>
type _ = AssertTrue<Equal<UserId, string>, 'should be string'> // FAILS
Brands intentionally break equality with the base type. Use Unbrand:
type _ = AssertTrue<Equal<Unbrand<UserId>, string>, 'underlying string'>
Branding identity primitives that never leave the codebase
type LineNumber = Brand<number, 'LineNumber'>
function reportError(line: LineNumber) { /* ... */ }
If LineNumber is only ever produced and consumed inside the same module, you've added type ceremony without any safety. Brand at module boundaries, not within them.
See also
- API / brand
- API / equality — why
Equal<UserId, string>isfalse.