جارٍ التحميل...
جارٍ التحميل...
On the Templately admin we had four different ID types passed around as strings — TemplateId, CategoryId, UserId, OrganizationId — and the bugs that came from mixing them were hard to spot in code review. Branded types fixed that. Notes on the pattern and when it actually pays off.
On the Templately admin codebase, we hit this bug at least three times: a function expecting a TemplateId got passed a CategoryId. Both are strings. TypeScript happily allowed it. The actual error only showed up at runtime when the API returned an empty result and the UI silently rendered an empty state. Each time, the fix was the same — add a runtime check, write a regression test, move on. Branded types eliminated the whole class.
The TypeScript type system is structural — two types with the same shape are interchangeable. That is usually a feature, but it becomes a liability when different concepts happen to share the same primitive. A UserId and a PostId are both strings. A USD amount and a EUR amount are both numbers. Mixing them up is a bug TypeScript happily lets through.
A branded type is a primitive tagged with a phantom field that exists only in the type system. The runtime value is unchanged — just a string or a number — but TypeScript treats it as distinct from any other string or number.
// The brand helper — zero runtime cost
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
// Your domain types
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
type Email = Brand<string, 'Email'>;
// Constructor — the only way to create a branded value
function UserId(raw: string): UserId {
return raw as UserId;
}
function Email(raw: string): Email {
if (!raw.includes('@')) {
throw new Error('Invalid email');
}
return raw as Email;
}The moment you brand your IDs, the compiler starts catching bugs that used to require manual code review. Passing a PostId where a UserId is expected becomes a type error, not a production incident.
function getUser(id: UserId): Promise<User> { /* ... */ }
function getPost(id: PostId): Promise<Post> { /* ... */ }
const userId = UserId('user_123');
const postId = PostId('post_456');
getUser(userId); // ✅ OK
getUser(postId); // ❌ Type error: PostId is not UserId
getUser('user_123'); // ❌ Type error: string is not UserIdمعلومة
The cast inside UserId() is the only place in your code that bypasses the type system. Funnel every untrusted string through a constructor and the rest of your codebase becomes type-safe by construction.
The real power of branded types emerges when you combine them with runtime validation. The constructor function becomes the single source of truth — a validated value is both runtime-verified and compile-time tracked.
type NonEmptyString = Brand<string, 'NonEmptyString'>;
type PositiveInt = Brand<number, 'PositiveInt'>;
function NonEmptyString(raw: string): NonEmptyString {
if (raw.length === 0) throw new Error('Empty string');
return raw as NonEmptyString;
}
function PositiveInt(raw: number): PositiveInt {
if (!Number.isInteger(raw) || raw <= 0) {
throw new Error('Not a positive integer');
}
return raw as PositiveInt;
}
// Function signatures now encode their invariants
function chargeCents(amount: PositiveInt): void {
// No need to check if amount > 0 — the type guarantees it
stripe.charges.create({ amount });
}Branded types shine for anywhere two numbers with different units get mixed up. Adding USD to EUR, milliseconds to seconds, pixels to rems — all classic bugs that brands prevent at the type level.
type USD = Brand<number, 'USD'>;
type EUR = Brand<number, 'EUR'>;
type Milliseconds = Brand<number, 'Milliseconds'>;
type Seconds = Brand<number, 'Seconds'>;
function convertUsdToEur(amount: USD, rate: number): EUR {
return (amount * rate) as EUR;
}
const price = 100 as USD;
const discount = 20 as USD;
const total = price - discount; // OK — both USD
const eur = 50 as EUR;
const mixed = price + eur; // ❌ Type errorنصيحة
Use branded types for IDs, monetary amounts, time durations, measurement units, SQL query strings, and anything that users of your API might confuse. Do not brand purely-internal values — the ergonomic cost is not worth it.
Branded types add friction at the boundaries of your system — you need to convert raw strings from HTTP requests, databases, and third-party libraries into branded values via constructor functions. This friction is actually a feature. It forces validation at the edges and keeps your core logic operating on trusted values.
On the Templately admin, after we branded TemplateId, CategoryId, UserId, and OrganizationId, the ID-confusion bugs stopped recurring. We did not catch a single new instance in the next six months — the compiler caught them at PR time instead. The friction at the system boundaries was real (every API response had to go through a constructor) but the friction is the point: it forces validation at the edges. Branded types feel awkward for the first day and liberating for the next several years. For any codebase large enough to have multiple ID types or unit systems, they pay for themselves quickly.
المزيد في TypeScript
On TubeOnAI we wired Zod into every API boundary, every form, and every Firebase response — and the next month our error tracker stopped recording shape-mismatch bugs entirely. Notes on the Zod patterns I now reach for by default.
A handful of TypeScript 5.x features changed how I write code day-to-day on the Templately admin and sapan.dev. Notes on the ones I reach for constantly — `satisfies`, `const` type parameters, `using` — and the ones I have not yet found a real use for.
On TubeOnAI, the original load/error/data form state was three independent booleans — and we kept hitting bugs where data was set AND loading was true. Switched to a single discriminated union and the entire class of bugs went away. Notes on the pattern that changed how I model state.