···11+# Branded Types In Depth
22+33+This article explains how to create distinct types from primitives, preventing an entire category of bugs at compile time.
44+55+---
66+77+## The Problem: Type Aliases Aren't Enough
88+99+Consider a function that processes an order:
1010+1111+```typescript
1212+type OrderId = string
1313+type CustomerId = string
1414+type ProductId = string
1515+1616+const processOrder = (
1717+ orderId: OrderId,
1818+ customerId: CustomerId,
1919+ productId: ProductId
2020+): void => {
2121+ // ... process the order
2222+}
2323+```
2424+2525+This looks type-safe. Each parameter has a distinct type. But try this:
2626+2727+```typescript
2828+const orderId: OrderId = "order-123"
2929+const customerId: CustomerId = "cust-456"
3030+const productId: ProductId = "prod-789"
3131+3232+// Oops - arguments are in the wrong order!
3333+processOrder(customerId, orderId, productId)
3434+```
3535+3636+**This compiles without error.** TypeScript sees three strings and says "looks good."
3737+3838+The type aliases are just documentation. At the type level, `OrderId`, `CustomerId`, and `ProductId` are all the same type: `string`.
3939+4040+### Real-World Impact
4141+4242+This isn't a contrived example. In production codebases:
4343+4444+- Database queries get the wrong ID type, returning no results or wrong data
4545+- API calls send customer IDs where order IDs are expected
4646+- Security checks pass the wrong user ID, granting unauthorized access
4747+- Financial calculations use amounts in the wrong currency
4848+4949+These bugs are hard to find because:
5050+- The code compiles
5151+- It often works (strings are strings)
5252+- Failures are subtle (wrong data, not crashes)
5353+- Tests might not catch every permutation
5454+5555+---
5656+5757+## The Solution: Branded Types
5858+5959+A branded type adds a phantom "brand" to the base type, making it incompatible with other brands.
6060+6161+```typescript
6262+// Declare a unique symbol for branding
6363+declare const __brand: unique symbol
6464+type Brand<B> = { [__brand]: B }
6565+6666+// Branded type combines the base type with a brand
6767+type Branded<T, B> = T & Brand<B>
6868+```
6969+7070+Now we can create distinct types:
7171+7272+```typescript
7373+type OrderId = Branded<string, "OrderId">
7474+type CustomerId = Branded<string, "CustomerId">
7575+type ProductId = Branded<string, "ProductId">
7676+```
7777+7878+These are all strings at runtime, but the compiler treats them as incompatible:
7979+8080+```typescript
8181+const orderId = "order-123" as OrderId
8282+const customerId = "cust-456" as CustomerId
8383+8484+processOrder(customerId, orderId, productId)
8585+// ^^^^^^^^^^
8686+// Error: Argument of type 'CustomerId' is not assignable
8787+// to parameter of type 'OrderId'
8888+```
8989+9090+The compiler catches the bug before the code runs.
9191+9292+### How It Works
9393+9494+The `Brand<B>` type adds a phantom property that only exists at the type level:
9595+9696+```typescript
9797+type Brand<B> = { [__brand]: B }
9898+```
9999+100100+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.
101101+102102+When you write:
103103+104104+```typescript
105105+type OrderId = Branded<string, "OrderId">
106106+```
107107+108108+TypeScript sees:
109109+110110+```typescript
111111+type OrderId = string & { [__brand]: "OrderId" }
112112+```
113113+114114+A `CustomerId` is:
115115+116116+```typescript
117117+type CustomerId = string & { [__brand]: "CustomerId" }
118118+```
119119+120120+These aren't assignable to each other because the brand values differ.
121121+122122+---
123123+124124+## Smart Constructors
125125+126126+Casting with `as` works but isn't safe:
127127+128128+```typescript
129129+const orderId = "not-a-valid-order" as OrderId // Compiles!
130130+```
131131+132132+Smart constructors validate input and return the branded type:
133133+134134+```typescript
135135+import { type Option, some, none } from "purus-ts"
136136+137137+type OrderId = Branded<string, "OrderId">
138138+139139+const OrderId = (value: string): Option<OrderId> => {
140140+ if (!value.startsWith("order-")) {
141141+ return none
142142+ }
143143+ return some(value as OrderId)
144144+}
145145+```
146146+147147+Now invalid IDs are caught at creation:
148148+149149+```typescript
150150+const valid = OrderId("order-123") // Some(OrderId)
151151+const invalid = OrderId("cust-456") // None
152152+```
153153+154154+### Validation Patterns
155155+156156+For simple cases, return the branded type directly:
157157+158158+```typescript
159159+const OrderId = (s: string): OrderId => s as OrderId
160160+```
161161+162162+For validation, return `Option`:
163163+164164+```typescript
165165+const Email = (s: string): Option<Email> =>
166166+ s.includes("@") ? some(s as Email) : none
167167+```
168168+169169+For validation with error context, return `Result`:
170170+171171+```typescript
172172+type EmailError = { _tag: "InvalidEmail"; value: string }
173173+174174+const Email = (s: string): Result<Email, EmailError> =>
175175+ s.includes("@")
176176+ ? ok(s as Email)
177177+ : err({ _tag: "InvalidEmail", value: s })
178178+```
179179+180180+---
181181+182182+## Real Examples
183183+184184+### Domain IDs
185185+186186+```typescript
187187+import { type Branded, brand } from "purus-ts"
188188+189189+type UserId = Branded<string, "UserId">
190190+type TeamId = Branded<string, "TeamId">
191191+type ProjectId = Branded<string, "ProjectId">
192192+193193+const UserId = (s: string): UserId => brand(s)
194194+const TeamId = (s: string): TeamId => brand(s)
195195+const ProjectId = (s: string): ProjectId => brand(s)
196196+197197+// These can never be swapped by accident
198198+const addUserToTeam = (userId: UserId, teamId: TeamId): void => {
199199+ // ...
200200+}
201201+```
202202+203203+### Validated Strings
204204+205205+```typescript
206206+type Email = Branded<string, "Email">
207207+type Url = Branded<string, "Url">
208208+type PhoneNumber = Branded<string, "PhoneNumber">
209209+210210+const Email = (s: string): Option<Email> =>
211211+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) ? some(s as Email) : none
212212+213213+const Url = (s: string): Option<Url> => {
214214+ try {
215215+ new URL(s)
216216+ return some(s as Url)
217217+ } catch {
218218+ return none
219219+ }
220220+}
221221+```
222222+223223+### Numeric Constraints
224224+225225+purus includes refinement types for common numeric constraints:
226226+227227+```typescript
228228+import { type Refined, positive, nonNegative, integer } from "purus-ts"
229229+230230+type Positive = Refined<number, "Positive">
231231+type NonNegative = Refined<number, "NonNegative">
232232+type Integer = Refined<number, "Integer">
233233+234234+const age = positive(25) // Some(Positive)
235235+const invalid = positive(-5) // None
236236+237237+const count = integer(10) // Some(Integer)
238238+const notInt = integer(10.5) // None
239239+```
240240+241241+Use these for:
242242+- Array indices (NonNegative Integer)
243243+- Quantities (Positive Integer)
244244+- Percentages (Normalized: 0-1)
245245+- Prices (NonNegative)
246246+247247+### Currency and Units
248248+249249+Prevent mixing currencies:
250250+251251+```typescript
252252+type USD = Branded<number, "USD">
253253+type EUR = Branded<number, "EUR">
254254+type GBP = Branded<number, "GBP">
255255+256256+const addPrices = (a: USD, b: USD): USD => (a + b) as USD
257257+258258+const priceUS: USD = 100 as USD
259259+const priceEU: EUR = 85 as EUR
260260+261261+addPrices(priceUS, priceEU)
262262+// ^^^^^^^^
263263+// Error: Argument of type 'EUR' is not assignable to type 'USD'
264264+```
265265+266266+For more sophisticated unit tracking, see the Units module in purus.
267267+268268+---
269269+270270+## Type-Level Testing
271271+272272+You can verify that branded types are incompatible using type-level assertions:
273273+274274+```typescript
275275+type OrderId = Branded<string, "OrderId">
276276+type CustomerId = Branded<string, "CustomerId">
277277+278278+// Type-level test: these should not be assignable
279279+type AssertNotAssignable<T, U> = T extends U ? never : true
280280+281281+// If OrderId were assignable to CustomerId, this would be 'never'
282282+type Test1 = AssertNotAssignable<OrderId, CustomerId> // true
283283+type Test2 = AssertNotAssignable<CustomerId, OrderId> // true
284284+285285+// Both should be assignable to string
286286+type Test3 = OrderId extends string ? true : never // true
287287+type Test4 = CustomerId extends string ? true : never // true
288288+```
289289+290290+These tests run at compile time. If the types become compatible (due to a refactoring error), the tests fail.
291291+292292+### Testing with @ts-expect-error
293293+294294+Use TypeScript's `@ts-expect-error` directive:
295295+296296+```typescript
297297+const orderId: OrderId = OrderId("order-123")
298298+const customerId: CustomerId = CustomerId("cust-456")
299299+300300+// This should fail to compile
301301+// @ts-expect-error - OrderId is not assignable to CustomerId
302302+const wrong: CustomerId = orderId
303303+```
304304+305305+If the assignment ever becomes valid, TypeScript complains about the unused `@ts-expect-error`.
306306+307307+---
308308+309309+## Production Patterns
310310+311311+### Pattern 1: ID Factory
312312+313313+Create a factory for consistent ID handling:
314314+315315+```typescript
316316+const createIdType = <B extends string>(brand: B) => {
317317+ type Id = Branded<string, B>
318318+319319+ return {
320320+ create: (value: string): Id => value as Id,
321321+ validate: (value: string, prefix: string): Option<Id> =>
322322+ value.startsWith(prefix) ? some(value as Id) : none,
323323+ toString: (id: Id): string => id,
324324+ }
325325+}
326326+327327+const OrderId = createIdType("OrderId")
328328+const CustomerId = createIdType("CustomerId")
329329+330330+type OrderId = ReturnType<typeof OrderId.create>
331331+type CustomerId = ReturnType<typeof CustomerId.create>
332332+```
333333+334334+### Pattern 2: Branded with Metadata
335335+336336+Store additional compile-time information:
337337+338338+```typescript
339339+type Currency = "USD" | "EUR" | "GBP"
340340+341341+declare const __currency: unique symbol
342342+type Money<C extends Currency> = number & { [__currency]: C }
343343+344344+const usd = (amount: number): Money<"USD"> => amount as Money<"USD">
345345+const eur = (amount: number): Money<"EUR"> => amount as Money<"EUR">
346346+347347+// Type-safe currency operations
348348+const addMoney = <C extends Currency>(a: Money<C>, b: Money<C>): Money<C> =>
349349+ (a + b) as Money<C>
350350+351351+const total = addMoney(usd(100), usd(50)) // Money<"USD">
352352+```
353353+354354+### Pattern 3: Gradual Adoption
355355+356356+Introduce branded types incrementally:
357357+358358+```typescript
359359+// Step 1: Add types but keep accepting strings
360360+type UserId = Branded<string, "UserId">
361361+362362+const getUser = (id: UserId | string): User => {
363363+ // ... existing implementation
364364+}
365365+366366+// Step 2: Update callers to use branded types
367367+const userId = UserId("user-123")
368368+const user = getUser(userId)
369369+370370+// Step 3: Remove string from parameter type
371371+const getUser = (id: UserId): User => {
372372+ // ... now type-safe
373373+}
374374+```
375375+376376+### Pattern 4: Database Integration
377377+378378+Map branded types to/from database values:
379379+380380+```typescript
381381+type UserId = Branded<string, "UserId">
382382+383383+// From database
384384+const rowToUser = (row: DbRow): User => ({
385385+ id: row.id as UserId, // Trust the database
386386+ name: row.name,
387387+})
388388+389389+// To database
390390+const userToRow = (user: User): DbRow => ({
391391+ id: user.id, // UserId is a string at runtime
392392+ name: user.name,
393393+})
394394+```
395395+396396+---
397397+398398+## When to Use Branded Types
399399+400400+### Good Use Cases
401401+402402+- **Domain IDs** - UserId, OrderId, ProductId
403403+- **Validated strings** - Email, URL, PhoneNumber
404404+- **Numeric constraints** - Positive, NonNegative, Percentage
405405+- **Units** - USD vs EUR, Meters vs Feet
406406+- **State markers** - Verified, Encrypted, Sanitized
407407+408408+### When to Skip
409409+410410+- **Single-use values** - If there's only one ID type, no confusion possible
411411+- **Throwaway scripts** - The ceremony isn't worth it
412412+- **Performance-critical hot paths** - The type assertions have negligible cost, but simpler is sometimes better
413413+414414+### Signs You Need Them
415415+416416+- Functions with multiple string/number parameters
417417+- Bugs from swapped arguments
418418+- Database queries returning wrong data
419419+- API calls with mixed-up IDs
420420+421421+---
422422+423423+## Key Takeaways
424424+425425+1. **Type aliases don't prevent mix-ups** - `type UserId = string` is just documentation
426426+2. **Branded types add compile-time distinction** - Same runtime, different types
427427+3. **Smart constructors add validation** - Return Option/Result for invalid input
428428+4. **Zero runtime cost** - The brand only exists in the type system
429429+5. **Gradual adoption works** - Start with critical IDs, expand over time
430430+431431+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.
432432+433433+---
434434+435435+## See Also
436436+437437+- [Tutorial Chapter 6: Branded Types](../tutorial/06-branded-types.md) - Hands-on introduction
438438+- [Workflow Engine Example](../../../examples/workflow-engine/) - Branded types in action
439439+- [Typestate Pattern](./typestate-pattern.md) - Encoding state machines with phantom types