Branded Types & Nominal Typing
Quick Overview
TypeScript compares types by shape, so a `UserId` and a raw `string` are interchangeable to the compiler. A brand adds a phantom field that exists only at compile time, making the two genuinely incompatible.
Deep Dive
1. Structural vs. Nominal Typing
TypeScript uses a Structural Type System (duck typing). If two types share the same structure, they are considered compatible, even if they have different intent (e.g. UserId and ProductId both being string).
2. What is a Branded Type?
A Branded Type (or Nominal Type) attaches a unique, phantom compile-time "brand" tag to a primitive type to ensure different domain identifiers cannot be accidentally swapped!
3. Creating a Branded Type
// Unique brand tag trick
declare const __brand: unique symbol;
type Brand<T, K extends string> = T & { readonly [__brand]: K };
// Define nominal domain types!
type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;
// Constructor helper functions (Smart Constructors)
function makeUserId(id: string): UserId {
return id as UserId;
}
function makeProductId(id: string): ProductId {
return id as ProductId;
}
4. Compile-Time Safety Guarantee
function fetchUserProfile(userId: UserId) {
console.log("Fetching profile for:", userId);
}
const uId = makeUserId("usr_101");
const pId = makeProductId("prod_999");
fetchUserProfile(uId); // Valid!
// fetchUserProfile(pId); // Error: Type 'ProductId' is not assignable to type 'UserId'!
// fetchUserProfile("raw_string"); // Error: Type 'string' is not assignable to 'UserId'!
5. Common Mistakes
- Over-branding Simple Apps: Branded types add minor boilerplate. Use them primarily for domain identifiers (e.g.,
USDvsEUR,UserIdvsOrderId) where mixing primitives causes severe financial or data corruption bugs!
6. Next Steps
Use branded types for domain IDs and monetary units. Next, let me explain Strict Compiler Flags in tsconfig.json!
Next Up in TypeScript & Type System Mechanics
TypeScript Primitives & Explicit Type Annotations
TypeScript erases entirely at build time, so a `string` annotation produces no runtime check whatsoever. A value arriving from an API is whatever the server actually sent, regardless of what the annotation claims.
Type Inference & Literal Types (const vs. let)
`let x = "GET"` infers `string` because the binding can change; `const x = "GET"` infers the literal `"GET"`. That widening rule is why object properties need `as const` to stay narrow.
Arrays, Tuples & Readonly Modifiers
`string[]` is any number of strings; `[string, number]` is exactly two, in that order. `readonly` removes the mutating methods at compile time, so `push` becomes a type error rather than a runtime surprise.
