Skip to content
webtype.orgwebtype.org

    ↑↓ move · ⏎ open · esc close

    Patterns

    How do I stop a UserId being passed where an OrderId was expected, when both are just strings?

    TypeScript is structural, so two aliases of string are the same type. A phantom property makes them different without changing the value.

    The recipe

    declare const brand: unique symbol
    
    type Brand<T, Name extends string> = T & { readonly [brand]: Name }
    
    type UserId = Brand<string, 'UserId'>
    type OrderId = Brand<string, 'OrderId'>
    
    const asUserId = (raw: string): UserId => raw as UserId
    
    declare function findUser(id: UserId): void
    
    const id = asUserId('u_1')
    findUser(id)

    The build compiles this and checks each result below.

    How it works

    1. 01
      declare const brand: unique symbol

      A unique symbol gives a key nothing else can produce. A string key would let an unrelated object accidentally satisfy the brand.

    2. 02
      type Brand<T, Name extends string> = T & { readonly [brand]: Name }

      The intersection adds a property that exists only in the type. At run time the value is still exactly the string you started with.

    3. 03
      const asUserId = (raw: string): UserId => raw as UserId

      One cast, in one place, is the price. Everywhere else the compiler enforces the distinction for free — which is why this function should be the only way in.

    What you get

    Where it goes wrong

    The brand is erased, so JSON.parse will happily hand you something typed UserId that never went through your constructor. Brands guard your code against itself; they do not validate data crossing a boundary.

    Takeaway

    Reach for a brand when two values share a representation and must not share a meaning. Keep exactly one function that produces each one.

    See also

    Patterns