Skip to content

Instantly share code, notes, and snippets.

@christhekeele
Last active January 22, 2026 19:38
Show Gist options
  • Select an option

  • Save christhekeele/acb739b1db0f30c52ba209f82f91b274 to your computer and use it in GitHub Desktop.

Select an option

Save christhekeele/acb739b1db0f30c52ba209f82f91b274 to your computer and use it in GitHub Desktop.
Nominal flavored and branded types for typescript.
//////
// 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