Last active
August 28, 2025 22:45
-
-
Save christhekeele/acb739b1db0f30c52ba209f82f91b274 to your computer and use it in GitHub Desktop.
Nominal flavored and branded types for typescript.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // BRAND AND FLAVOR NOMINAL TYPING | |
| // | |
| // Typescript typing is structural: it only care about the | |
| // data shape and primitive types. For example, a type alias | |
| // is interchangable with its underlying primitive type: | |
| // type FooCode = string; | |
| // | |
| // Multiple type aliases are interchangable with each other: | |
| // type FooCode = string; | |
| // type BarCode = string; | |
| // const foo: FooCode = "fizzbuzz"; | |
| // const bar: BarCode = foo; | |
| // | |
| // Nominal typing lets us tell typescript, | |
| // "Because we have given this type a name, | |
| // it is incompatable with similarly-structured types | |
| // of a different name". | |
| // | |
| // For more, see references: | |
| // https://spin.atomicobject.com/typescript-flexible-nominal-typing/ | |
| // https://prosopo.io/blog/typescript-branding/ | |
| const NominalType = Symbol('NominalType'); | |
| // FLAVORING | |
| // | |
| // A nominal "flavored" type can accept the underlying type, but | |
| // cannot be used with other flavors elsewhere: | |
| // | |
| // type FooCode = Flavor<string, "Foo">; | |
| // type BarCode = Flavor<string, "Bar">; | |
| // const foo: FooCode = "fizzbuzz"; | |
| // const bar: BarCode = foo; | |
| // !> Type 'FooCode' is not assignable to type 'BarCode'. | |
| // | |
| // | |
| // This allows us to begin using the type system to declare | |
| // exactly what kind of string we want to be working with, | |
| // and complain if we accidentally mix string types in datastructures. | |
| // However, Flavor types can be assigned generic types, | |
| // like foo = "fizzbuzz" above. | |
| export type Flavor<Type, Name> = Type & { | |
| [NominalType]?: Name; | |
| }; | |
| // BRANDING | |
| // | |
| // A nominal "branded" type behaves the same, but will not accept | |
| // the underlying underlying primiative type without explicit co-ercion: | |
| // | |
| // type FooCode = Flavor<string, "Foo">; | |
| // const foo: FooCode = "fizzbuzz"; | |
| // !> Type 'string' is not assignable to type 'FooCode'. | |
| // | |
| // This forces us to explicitly declare when we are allowing a primitive type | |
| // to become a stronger branded type: | |
| // | |
| // type FooCode = Flavor<string, "Foo">; | |
| // const foo: FooCode = "fizzbuzz" as FooCode; | |
| export type Brand<Type, Name> = Type & { | |
| [NominalType]: Name; | |
| }; | |
| // POLYMORPHISM | |
| // | |
| // Nominal types can share names, but will still not be | |
| // assignable to each other: | |
| // | |
| // type FooCode = Flavor<string, "Foo">; | |
| // type FooId = Flavor<number, "Foo">; | |
| // | |
| // const fooCode: FooCode = "fizz"; | |
| // const fooId: FooId = fooCode; | |
| // !> Type 'FooCode' is not assignable to type 'FooId'. | |
| // !> Type 'FooCode' is not assignable to type 'number'. | |
| // | |
| // You can construct explicit unions to work around this | |
| // if you want polymorphism: | |
| // | |
| // type FooCode = Flavor<string, "Foo">; | |
| // type FooId = Flavor<number, "Foo">; | |
| // type Fooish = FooCode | FooId; | |
| // | |
| // const fooCode: FooCode = "fizz"; | |
| // const fooId: FooId = 1; | |
| // let fooish: Fooish; | |
| // fooish = fooCode; | |
| // fooish = fooId; | |
| // | |
| // Alternatively, you can construct a nominal type polymorphic | |
| // on any type with the same name, provided you are using Brands: | |
| // | |
| // type FooCode = Brand<string, "Foo">; | |
| // type FooId = Brand<number, "Foo">; | |
| // type Fooish = Named<"Foo">; | |
| // | |
| // const fooCode: FooCode = "fizz" as FooCode; | |
| // const fooId: FooId = 1 as FooId; | |
| // let fooish: Fooish; | |
| // fooish = fooCode; | |
| // fooish = fooId; | |
| // | |
| // If using Flavors instead of Brands, you will get the error: | |
| // | |
| // type FooCode = Flavor<string, "Foo">; | |
| // type FooId = Flavor<number, "Foo">; | |
| // type Fooish = Named<"Foo">; | |
| // | |
| // const fooCode: FooCode = "fizz" as FooCode; | |
| // const fooId: FooId = 1 as FooId; | |
| // let fooish: Fooish; | |
| // fooish = fooCode; | |
| // !> Type 'FooCode' is not assignable to type 'Fooish'. | |
| // !> Types of property '[NominalType]' are incompatible. | |
| // !> Type '"Foo" | undefined' is not assignable to type '"Foo"'. | |
| // !> Type 'undefined' is not assignable to type '"Foo"'. | |
| export type Named<Name> = Record<typeof NominalType, Name>; | |
| // ERASURE | |
| // | |
| // As Flavors and Brands are assignable to their primitives, you usually | |
| // don't need to worry much about erasing the norminal type. | |
| // However, should you want to, use the Primitive helper. | |
| // type FooCode = Brand<string, "Foo">; | |
| // const fooCode: FooCode = "fizz" as FooCode; | |
| // // This works fine: | |
| // // const string: string = fooCode; | |
| // // But for explicitness, you can also do: | |
| // const string = fooCode as Primitive<string>; | |
| export type Primitive<Type> = Exclude<Type, typeof NominalType>; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment