Strongest way to do nominal types in Typescript?

740 Views Asked by At

I've seen many different ways to do nominal types in Typescript, but they all seem to fall short in some way. I'd like all these properties to hold:

  1. Must have clear (not necessarily concise, but bonus points if so) compiler error messages communicating which opaque types, e.g. Type 'GBP' is not assignable to type 'JPY'.
  2. Must be truly unique to avoid accidentally matching similar opaque types, i.e. no __tag__ keys, must use unique symbol.
  3. Must be able to have safe generic functions taking opaque types sharing the same underlying primitive type, e.g. <A>(Opaque<number, A>) => Opaque<number, A>.

More bonus points for a syntactically clean interface, but I understand that's subjective.

1

There are 1 best solutions below

0
On

This is the best approach I've discovered:

namespace Unique {
  export declare const Newtype: unique symbol
  export declare const JPY: unique symbol
  export declare const GBP: unique symbol
}

type Newtype<A, B extends symbol> = A & { readonly [Unique.Newtype]: B }

type JPY = Newtype<number, typeof Unique.JPY>
type GBP = Newtype<number, typeof Unique.GBP>

const test: <A extends symbol>(a: Newtype<number, A>, b: Newtype<number, A>) => Newtype<number, A>
  = (a, b) => a + b as any // massage the type checker a bit

// fails
test(10 as GBP, 10)
test(10 as GBP, 10 as JPY)

// passes
test(10 as GBP, 10 as GBP)
test(10 as JPY, 10 as JPY)
  1. holds, but no bonus points here because you end up with some really nasty error messages containing file paths (live example, see "Errors"): Newtype<number, typeof import("file:///input").JPY>. I'm hoping there's a way involving interface extends or similar to make this cleaner.

  2. holds because both Unique.Newtype and Unique.JPY are unique symbols.

  3. holds because we can use the structure of Newtype to ensure the types are definitely Newtype, due to the fact that it's defined in terms of Unique.Newtype which is unique symbol.