An educational pure functional programming library in TypeScript
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add concept article: Branded Types In Depth

+439
+439
docs/guides/concepts/branded-types.md
··· 1 + # Branded Types In Depth 2 + 3 + This article explains how to create distinct types from primitives, preventing an entire category of bugs at compile time. 4 + 5 + --- 6 + 7 + ## The Problem: Type Aliases Aren't Enough 8 + 9 + Consider a function that processes an order: 10 + 11 + ```typescript 12 + type OrderId = string 13 + type CustomerId = string 14 + type ProductId = string 15 + 16 + const processOrder = ( 17 + orderId: OrderId, 18 + customerId: CustomerId, 19 + productId: ProductId 20 + ): void => { 21 + // ... process the order 22 + } 23 + ``` 24 + 25 + This looks type-safe. Each parameter has a distinct type. But try this: 26 + 27 + ```typescript 28 + const orderId: OrderId = "order-123" 29 + const customerId: CustomerId = "cust-456" 30 + const productId: ProductId = "prod-789" 31 + 32 + // Oops - arguments are in the wrong order! 33 + processOrder(customerId, orderId, productId) 34 + ``` 35 + 36 + **This compiles without error.** TypeScript sees three strings and says "looks good." 37 + 38 + The type aliases are just documentation. At the type level, `OrderId`, `CustomerId`, and `ProductId` are all the same type: `string`. 39 + 40 + ### Real-World Impact 41 + 42 + This isn't a contrived example. In production codebases: 43 + 44 + - Database queries get the wrong ID type, returning no results or wrong data 45 + - API calls send customer IDs where order IDs are expected 46 + - Security checks pass the wrong user ID, granting unauthorized access 47 + - Financial calculations use amounts in the wrong currency 48 + 49 + These bugs are hard to find because: 50 + - The code compiles 51 + - It often works (strings are strings) 52 + - Failures are subtle (wrong data, not crashes) 53 + - Tests might not catch every permutation 54 + 55 + --- 56 + 57 + ## The Solution: Branded Types 58 + 59 + A branded type adds a phantom "brand" to the base type, making it incompatible with other brands. 60 + 61 + ```typescript 62 + // Declare a unique symbol for branding 63 + declare const __brand: unique symbol 64 + type Brand<B> = { [__brand]: B } 65 + 66 + // Branded type combines the base type with a brand 67 + type Branded<T, B> = T & Brand<B> 68 + ``` 69 + 70 + Now we can create distinct types: 71 + 72 + ```typescript 73 + type OrderId = Branded<string, "OrderId"> 74 + type CustomerId = Branded<string, "CustomerId"> 75 + type ProductId = Branded<string, "ProductId"> 76 + ``` 77 + 78 + These are all strings at runtime, but the compiler treats them as incompatible: 79 + 80 + ```typescript 81 + const orderId = "order-123" as OrderId 82 + const customerId = "cust-456" as CustomerId 83 + 84 + processOrder(customerId, orderId, productId) 85 + // ^^^^^^^^^^ 86 + // Error: Argument of type 'CustomerId' is not assignable 87 + // to parameter of type 'OrderId' 88 + ``` 89 + 90 + The compiler catches the bug before the code runs. 91 + 92 + ### How It Works 93 + 94 + The `Brand<B>` type adds a phantom property that only exists at the type level: 95 + 96 + ```typescript 97 + type Brand<B> = { [__brand]: B } 98 + ``` 99 + 100 + The `__brand` symbol is declared but never defined (`declare const`), so it has no runtime cost. But TypeScript sees it in the type and uses it for compatibility checks. 101 + 102 + When you write: 103 + 104 + ```typescript 105 + type OrderId = Branded<string, "OrderId"> 106 + ``` 107 + 108 + TypeScript sees: 109 + 110 + ```typescript 111 + type OrderId = string & { [__brand]: "OrderId" } 112 + ``` 113 + 114 + A `CustomerId` is: 115 + 116 + ```typescript 117 + type CustomerId = string & { [__brand]: "CustomerId" } 118 + ``` 119 + 120 + These aren't assignable to each other because the brand values differ. 121 + 122 + --- 123 + 124 + ## Smart Constructors 125 + 126 + Casting with `as` works but isn't safe: 127 + 128 + ```typescript 129 + const orderId = "not-a-valid-order" as OrderId // Compiles! 130 + ``` 131 + 132 + Smart constructors validate input and return the branded type: 133 + 134 + ```typescript 135 + import { type Option, some, none } from "purus-ts" 136 + 137 + type OrderId = Branded<string, "OrderId"> 138 + 139 + const OrderId = (value: string): Option<OrderId> => { 140 + if (!value.startsWith("order-")) { 141 + return none 142 + } 143 + return some(value as OrderId) 144 + } 145 + ``` 146 + 147 + Now invalid IDs are caught at creation: 148 + 149 + ```typescript 150 + const valid = OrderId("order-123") // Some(OrderId) 151 + const invalid = OrderId("cust-456") // None 152 + ``` 153 + 154 + ### Validation Patterns 155 + 156 + For simple cases, return the branded type directly: 157 + 158 + ```typescript 159 + const OrderId = (s: string): OrderId => s as OrderId 160 + ``` 161 + 162 + For validation, return `Option`: 163 + 164 + ```typescript 165 + const Email = (s: string): Option<Email> => 166 + s.includes("@") ? some(s as Email) : none 167 + ``` 168 + 169 + For validation with error context, return `Result`: 170 + 171 + ```typescript 172 + type EmailError = { _tag: "InvalidEmail"; value: string } 173 + 174 + const Email = (s: string): Result<Email, EmailError> => 175 + s.includes("@") 176 + ? ok(s as Email) 177 + : err({ _tag: "InvalidEmail", value: s }) 178 + ``` 179 + 180 + --- 181 + 182 + ## Real Examples 183 + 184 + ### Domain IDs 185 + 186 + ```typescript 187 + import { type Branded, brand } from "purus-ts" 188 + 189 + type UserId = Branded<string, "UserId"> 190 + type TeamId = Branded<string, "TeamId"> 191 + type ProjectId = Branded<string, "ProjectId"> 192 + 193 + const UserId = (s: string): UserId => brand(s) 194 + const TeamId = (s: string): TeamId => brand(s) 195 + const ProjectId = (s: string): ProjectId => brand(s) 196 + 197 + // These can never be swapped by accident 198 + const addUserToTeam = (userId: UserId, teamId: TeamId): void => { 199 + // ... 200 + } 201 + ``` 202 + 203 + ### Validated Strings 204 + 205 + ```typescript 206 + type Email = Branded<string, "Email"> 207 + type Url = Branded<string, "Url"> 208 + type PhoneNumber = Branded<string, "PhoneNumber"> 209 + 210 + const Email = (s: string): Option<Email> => 211 + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) ? some(s as Email) : none 212 + 213 + const Url = (s: string): Option<Url> => { 214 + try { 215 + new URL(s) 216 + return some(s as Url) 217 + } catch { 218 + return none 219 + } 220 + } 221 + ``` 222 + 223 + ### Numeric Constraints 224 + 225 + purus includes refinement types for common numeric constraints: 226 + 227 + ```typescript 228 + import { type Refined, positive, nonNegative, integer } from "purus-ts" 229 + 230 + type Positive = Refined<number, "Positive"> 231 + type NonNegative = Refined<number, "NonNegative"> 232 + type Integer = Refined<number, "Integer"> 233 + 234 + const age = positive(25) // Some(Positive) 235 + const invalid = positive(-5) // None 236 + 237 + const count = integer(10) // Some(Integer) 238 + const notInt = integer(10.5) // None 239 + ``` 240 + 241 + Use these for: 242 + - Array indices (NonNegative Integer) 243 + - Quantities (Positive Integer) 244 + - Percentages (Normalized: 0-1) 245 + - Prices (NonNegative) 246 + 247 + ### Currency and Units 248 + 249 + Prevent mixing currencies: 250 + 251 + ```typescript 252 + type USD = Branded<number, "USD"> 253 + type EUR = Branded<number, "EUR"> 254 + type GBP = Branded<number, "GBP"> 255 + 256 + const addPrices = (a: USD, b: USD): USD => (a + b) as USD 257 + 258 + const priceUS: USD = 100 as USD 259 + const priceEU: EUR = 85 as EUR 260 + 261 + addPrices(priceUS, priceEU) 262 + // ^^^^^^^^ 263 + // Error: Argument of type 'EUR' is not assignable to type 'USD' 264 + ``` 265 + 266 + For more sophisticated unit tracking, see the Units module in purus. 267 + 268 + --- 269 + 270 + ## Type-Level Testing 271 + 272 + You can verify that branded types are incompatible using type-level assertions: 273 + 274 + ```typescript 275 + type OrderId = Branded<string, "OrderId"> 276 + type CustomerId = Branded<string, "CustomerId"> 277 + 278 + // Type-level test: these should not be assignable 279 + type AssertNotAssignable<T, U> = T extends U ? never : true 280 + 281 + // If OrderId were assignable to CustomerId, this would be 'never' 282 + type Test1 = AssertNotAssignable<OrderId, CustomerId> // true 283 + type Test2 = AssertNotAssignable<CustomerId, OrderId> // true 284 + 285 + // Both should be assignable to string 286 + type Test3 = OrderId extends string ? true : never // true 287 + type Test4 = CustomerId extends string ? true : never // true 288 + ``` 289 + 290 + These tests run at compile time. If the types become compatible (due to a refactoring error), the tests fail. 291 + 292 + ### Testing with @ts-expect-error 293 + 294 + Use TypeScript's `@ts-expect-error` directive: 295 + 296 + ```typescript 297 + const orderId: OrderId = OrderId("order-123") 298 + const customerId: CustomerId = CustomerId("cust-456") 299 + 300 + // This should fail to compile 301 + // @ts-expect-error - OrderId is not assignable to CustomerId 302 + const wrong: CustomerId = orderId 303 + ``` 304 + 305 + If the assignment ever becomes valid, TypeScript complains about the unused `@ts-expect-error`. 306 + 307 + --- 308 + 309 + ## Production Patterns 310 + 311 + ### Pattern 1: ID Factory 312 + 313 + Create a factory for consistent ID handling: 314 + 315 + ```typescript 316 + const createIdType = <B extends string>(brand: B) => { 317 + type Id = Branded<string, B> 318 + 319 + return { 320 + create: (value: string): Id => value as Id, 321 + validate: (value: string, prefix: string): Option<Id> => 322 + value.startsWith(prefix) ? some(value as Id) : none, 323 + toString: (id: Id): string => id, 324 + } 325 + } 326 + 327 + const OrderId = createIdType("OrderId") 328 + const CustomerId = createIdType("CustomerId") 329 + 330 + type OrderId = ReturnType<typeof OrderId.create> 331 + type CustomerId = ReturnType<typeof CustomerId.create> 332 + ``` 333 + 334 + ### Pattern 2: Branded with Metadata 335 + 336 + Store additional compile-time information: 337 + 338 + ```typescript 339 + type Currency = "USD" | "EUR" | "GBP" 340 + 341 + declare const __currency: unique symbol 342 + type Money<C extends Currency> = number & { [__currency]: C } 343 + 344 + const usd = (amount: number): Money<"USD"> => amount as Money<"USD"> 345 + const eur = (amount: number): Money<"EUR"> => amount as Money<"EUR"> 346 + 347 + // Type-safe currency operations 348 + const addMoney = <C extends Currency>(a: Money<C>, b: Money<C>): Money<C> => 349 + (a + b) as Money<C> 350 + 351 + const total = addMoney(usd(100), usd(50)) // Money<"USD"> 352 + ``` 353 + 354 + ### Pattern 3: Gradual Adoption 355 + 356 + Introduce branded types incrementally: 357 + 358 + ```typescript 359 + // Step 1: Add types but keep accepting strings 360 + type UserId = Branded<string, "UserId"> 361 + 362 + const getUser = (id: UserId | string): User => { 363 + // ... existing implementation 364 + } 365 + 366 + // Step 2: Update callers to use branded types 367 + const userId = UserId("user-123") 368 + const user = getUser(userId) 369 + 370 + // Step 3: Remove string from parameter type 371 + const getUser = (id: UserId): User => { 372 + // ... now type-safe 373 + } 374 + ``` 375 + 376 + ### Pattern 4: Database Integration 377 + 378 + Map branded types to/from database values: 379 + 380 + ```typescript 381 + type UserId = Branded<string, "UserId"> 382 + 383 + // From database 384 + const rowToUser = (row: DbRow): User => ({ 385 + id: row.id as UserId, // Trust the database 386 + name: row.name, 387 + }) 388 + 389 + // To database 390 + const userToRow = (user: User): DbRow => ({ 391 + id: user.id, // UserId is a string at runtime 392 + name: user.name, 393 + }) 394 + ``` 395 + 396 + --- 397 + 398 + ## When to Use Branded Types 399 + 400 + ### Good Use Cases 401 + 402 + - **Domain IDs** - UserId, OrderId, ProductId 403 + - **Validated strings** - Email, URL, PhoneNumber 404 + - **Numeric constraints** - Positive, NonNegative, Percentage 405 + - **Units** - USD vs EUR, Meters vs Feet 406 + - **State markers** - Verified, Encrypted, Sanitized 407 + 408 + ### When to Skip 409 + 410 + - **Single-use values** - If there's only one ID type, no confusion possible 411 + - **Throwaway scripts** - The ceremony isn't worth it 412 + - **Performance-critical hot paths** - The type assertions have negligible cost, but simpler is sometimes better 413 + 414 + ### Signs You Need Them 415 + 416 + - Functions with multiple string/number parameters 417 + - Bugs from swapped arguments 418 + - Database queries returning wrong data 419 + - API calls with mixed-up IDs 420 + 421 + --- 422 + 423 + ## Key Takeaways 424 + 425 + 1. **Type aliases don't prevent mix-ups** - `type UserId = string` is just documentation 426 + 2. **Branded types add compile-time distinction** - Same runtime, different types 427 + 3. **Smart constructors add validation** - Return Option/Result for invalid input 428 + 4. **Zero runtime cost** - The brand only exists in the type system 429 + 5. **Gradual adoption works** - Start with critical IDs, expand over time 430 + 431 + Branded types are one of the highest-value patterns in TypeScript. A few lines of type definitions prevent bugs that are otherwise invisible until production. 432 + 433 + --- 434 + 435 + ## See Also 436 + 437 + - [Tutorial Chapter 6: Branded Types](../tutorial/06-branded-types.md) - Hands-on introduction 438 + - [Workflow Engine Example](../../../examples/workflow-engine/) - Branded types in action 439 + - [Typestate Pattern](./typestate-pattern.md) - Encoding state machines with phantom types