Last active
January 22, 2026 19:38
-
-
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 cares 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". | |
| // | |
| // This works by tagging the underlying value's structure at compile-time | |
| // with a `[NominalType]` property containing the name. This property | |
| // is never a part of the actual value at runtime, but it is enough | |
| // for the compiler to decide that the structure of the value only | |
| // matches declarations where the tag is expected when typechecking. | |
| // For more, see references: | |
| // https://spin.atomicobject.com/typescript-flexible-nominal-typing/ | |
| // https://prosopo.io/blog/typescript-branding/ | |
| /** | |
| * The key used to construct nominally typed types. | |
| */ | |
| export const NominalType = Symbol("NominalType"); | |
| ////// | |
| // FLAVORING | |
| //// | |
| // A nominal `Flavored` type can accept the underlying type, but | |
| // cannot be used with other flavors elsewhere: | |
| // | |
| // type FooCode = Flavored<string, "Foo">; | |
| // type BarCode = Flavored<string, "Bar">; | |
| // const foo: FooCode = "fizzbuzz"; | |
| // const bar: BarCode = foo; | |
| // !> Type 'FooCode' is not assignable to type 'BarCode'. | |
| // | |
| // However, `Flavored` types can be assigned generic types, | |
| // like foo = "fizzbuzz" above. | |
| /** | |
| * Defines a new flavored `Type` of a given `Name`. | |
| * | |
| * @typeParam Type - the underlying type to create a `Flavor` of. | |
| * @typeParam Name - the unique string name to recognize this flavored `Type` by. | |
| */ | |
| export type Flavored<Type, Name> = Type & Flavor<Name>; | |
| ////// | |
| // BRANDING | |
| //// | |
| // A nominal `Branded` type behaves the same, but will not accept | |
| // the underlying primitive type without explicit coercion: | |
| // | |
| // type FooCode = Branded<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 = Branded<string, "Foo">; | |
| // const foo: FooCode = "fizzbuzz" as FooCode; | |
| /** | |
| * Defines a new branded `Type` of a given `Name`. | |
| * | |
| * @typeParam Type - the underlying type to create a `Brand` of. | |
| * @typeParam Name - the unique string name to recognize this branded `Type` by. | |
| */ | |
| export type Branded<Type, Name> = Type & Brand<Name>; | |
| ////// | |
| // POLYMORPHISM | |
| //// | |
| // Nominal types can share names with different structure, | |
| // but will still not be assignable to each other: | |
| // | |
| // type FooCode = Branded<string, "Foo">; | |
| // type FooId = Flavored<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 = Branded<string, "Foo">; | |
| // type FooId = Flavored<number, "Foo">; | |
| // type Fooish = FooCode | FooId; | |
| // | |
| // const fooCode: FooCode = "fizz" as FooCode; | |
| // const fooId: FooId = 1; | |
| // let fooish: Fooish; | |
| // fooish = fooCode; | |
| // fooish = fooId; | |
| // | |
| // Alternatively, you can construct a Flavor type polymorphic | |
| // on any type with the same name: | |
| // | |
| // type FooCode = Flavored<string, "Foo">; | |
| // type FooId = Branded<number, "Foo">; | |
| // type Fooish = Flavor<"Foo">; | |
| // | |
| // const fooCode: FooCode = "fizz"; | |
| // const fooId: FooId = 1; | |
| // let fooish: Fooish; | |
| // fooish = fooCode; | |
| // fooish = fooId; | |
| /** | |
| * Defines a new type matching any type flavored with a given `Name`. | |
| * | |
| * @typeParam Name - the unique string name to recognize this `Flavor` by. | |
| */ | |
| export type Flavor<Name> = Partial<Brand<Name>>; | |
| // The same can be done for polymorphism with Brands. This polymorphism | |
| // only works for Branded types, and rejects Flavors: | |
| // | |
| // type FooCode = Branded<string, "Foo">; | |
| // type FooId = Flavored<number, "Foo">; | |
| // type Fooish = Brand<"Foo">; | |
| // | |
| // const fooCode: FooCode = "fizz"; | |
| // const fooId: FooId = 1; | |
| // let fooish: Fooish; | |
| // fooish = fooCode; | |
| // fooish = fooId; | |
| // !> Type 'FooId' 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"'. | |
| /** | |
| * Defines a new type matching any type branded with a given `Name`. | |
| * | |
| * @typeParam Name - the unique string name to recognize this `Brand` by. | |
| */ | |
| export type Brand<Name> = { [NominalType]: Name }; | |
| ////// | |
| // CLASSES | |
| //// | |
| // The Flavored and Branded helpers only work with primitive types | |
| // like strings and objects, but not classes: | |
| // | |
| // class Person { | |
| // constructor(public name: string) {} | |
| // } | |
| // | |
| // type Employee = Flavor<Person, "Employee"> | |
| // | |
| // const employee = new Employee("Peter Gibbons") | |
| // !> 'Employee' only refers to a type, but is being used as a value here. | |
| // | |
| // This is because "classes" are actually just constructor functions. | |
| // Instead, we will need to make a new constructor function from the existing one | |
| // that returns instances flavored and branded accordingly. | |
| type Constructor<T> = new (...args: any[]) => T; | |
| // To make flavors of an existing class, use the FlavoredClass function instead: | |
| // | |
| // type Employee = FlavoredClass(Person, "Employee"); | |
| // const employee = new Employee("Peter Gibbons"); | |
| /** | |
| * Defines a new class that creates `Class` objects flavored with a given `Name`. | |
| * | |
| * @param Class - the underlying class to create `Flavored` instances of. | |
| * @param Name - the unique string name to recognize this `Flavor` by. | |
| */ | |
| export const FlavoredClass = <Class>(ctor: Constructor<Class>, Name: string) => { | |
| return ctor as Constructor<Flavored<Class, typeof Name>>; | |
| }; | |
| // Similarly, make brands of an existing class using BrandedClass: | |
| // | |
| // type Employee = BrandedClass(Person, "Employee"); | |
| // const employee = new Employee("Peter Gibbons"); | |
| /** | |
| * Defines a new class that creates `Class` objects branded with a given `Name`. | |
| * | |
| * @param Class - the underlying class to create `Branded` instances of. | |
| * @param Name - the unique string name to recognize this `Brand` by. | |
| */ | |
| export const BrandedClass = <Class>(ctor: Constructor<Class>, Name: string) => { | |
| return ctor as Constructor<Branded<Class, typeof Name>>; | |
| }; | |
| ////// | |
| // ERASURE | |
| //// | |
| // As Flavored and Branded types are assignable to their underlying types, | |
| // you usually don't need to worry much about erasing the norminal type. | |
| // However, should you want to, use the Underlying helper. | |
| // | |
| // type FooCode = Branded<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 Underlying<string>; | |
| export type Underlying<Type> = Exclude<Type, typeof NominalType>; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment