Nominal Typing

The TypeScript type system is structural and this is one of the main motivating benefits. However, there are real-world use cases for a system where you want two variables to be differentiated because they have a different type name even if they have the same structure. A very common use case is identity structures (which are generally just strings with semantics associated with their name in languages like C#/Java).

There are a few patterns that have emerged in the community. I cover them in decreasing order of personal preference:

Using literal types

This pattern uses generics and literal types:

  1. /** Generic Id type */
  2. type Id<T extends string> = {
  3. type: T,
  4. value: string,
  5. }
  6. /** Specific Id types */
  7. type FooId = Id<'foo'>;
  8. type BarId = Id<'bar'>;
  9. /** Optional: contructors functions */
  10. const createFoo = (value: string): FooId => ({ type: 'foo', value });
  11. const createBar = (value: string): BarId => ({ type: 'bar', value });
  12. let foo = createFoo('sample')
  13. let bar = createBar('sample');
  14. foo = bar; // Error
  15. foo = foo; // Okay
  • Advantages
    • No need for any type assertions
  • Disadvantage
    • The structure {type,value} might not be desireable and need server serialization support

Using Enums

Enums in TypeScript offer a certain level of nominal typing. Two enum types aren’t equal if they differ by name. We can use this fact to provide nominal typing for types that are otherwise structurally compatible.

The workaround involves:

  • Creating a brand enum.
  • Creating the type as an intersection (&) of the brand enum + the actual structure.

This is demonstrated below where the structure of the types is just a string:

  1. // FOO
  2. enum FooIdBrand {}
  3. type FooId = FooIdBrand & string;
  4. // BAR
  5. enum BarIdBrand {}
  6. type BarId = BarIdBrand & string;
  7. /**
  8. * Usage Demo
  9. */
  10. var fooId: FooId;
  11. var barId: BarId;
  12. // Safety!
  13. fooId = barId; // error
  14. barId = fooId; // error
  15. // Newing up
  16. fooId = 'foo' as FooId;
  17. barId = 'bar' as BarId;
  18. // Both types are compatible with the base
  19. var str: string;
  20. str = fooId;
  21. str = barId;

Using Interfaces

Because numbers are type compatible with enums the previous technique cannot be used for them. Instead we can use interfaces to break the structural compatibility. This method is still used by the TypeScript compiler team, so worth mentioning. Using _ prefix and a Brand suffix is a convention I strongly recommend (and the one followed by the TypeScript team).

The workaround involves the following:

  • adding an unused property on a type to break structural compatibility.
  • using a type assertion when needing to new up or cast down.

This is demonstrated below:

  1. // FOO
  2. interface FooId extends String {
  3. _fooIdBrand: string; // To prevent type errors
  4. }
  5. // BAR
  6. interface BarId extends String {
  7. _barIdBrand: string; // To prevent type errors
  8. }
  9. /**
  10. * Usage Demo
  11. */
  12. var fooId: FooId;
  13. var barId: BarId;
  14. // Safety!
  15. fooId = barId; // error
  16. barId = fooId; // error
  17. fooId = <FooId>barId; // error
  18. barId = <BarId>fooId; // error
  19. // Newing up
  20. fooId = 'foo' as any;
  21. barId = 'bar' as any;
  22. // If you need the base string
  23. var str: string;
  24. str = fooId as any;
  25. str = barId as any;