Skip to content

Latest commit

 

History

History
118 lines (89 loc) · 3.8 KB

nominalTyping.md

File metadata and controls

118 lines (89 loc) · 3.8 KB

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:

/** Generic Id type */
type Id<T extends string> = {
  type: T,
  value: string,
}

/** Specific Id types */
type FooId = Id<'foo'>;
type BarId = Id<'bar'>;

/** Optional: constructors functions */
const createFoo = (value: string): FooId => ({ type: 'foo', value });
const createBar = (value: string): BarId => ({ type: 'bar', value });

let foo = createFoo('sample')
let bar = createBar('sample');

foo = bar; // Error
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:

// FOO
enum FooIdBrand { _ = "" };
type FooId = FooIdBrand & string;

// BAR
enum BarIdBrand  { _ = "" };
type BarId = BarIdBrand & string;

/**
 * Usage Demo
 */
var fooId: FooId;
var barId: BarId;

// Safety!
fooId = barId; // error
barId = fooId; // error

// Newing up
fooId = 'foo' as FooId;
barId = 'bar' as BarId;

// Both types are compatible with the base
var str: string;
str = fooId;
str = barId;

Note how the brand enums, FooIdBrand and BarIdBrand above, each have single member (_) that maps to the empty string, as specified by { _ = "" }. This forces TypeScript to infer that these are string-based enums, with values of type string, and not enums with values of type number. This is necessary because TypeScript infers an empty enum ({}) to be a numeric enum, and as of TypeScript 3.6.2 the intersection of a numeric enum and string is never.

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:

// FOO
interface FooId extends String {
    _fooIdBrand: string; // To prevent type errors
}

// BAR
interface BarId extends String {
    _barIdBrand: string; // To prevent type errors
}

/**
 * Usage Demo
 */
var fooId: FooId;
var barId: BarId;

// Safety!
fooId = barId; // error
barId = fooId; // error
fooId = <FooId>barId; // error
barId = <BarId>fooId; // error

// Newing up
fooId = 'foo' as any;
barId = 'bar' as any;

// If you need the base string
var str: string;
str = fooId as any;
str = barId as any;