Adding special values to types in TypeScript

[2020-01-26] dev, javascript, typescript
(Ad, please don’t block)

One way of understanding types is as sets of values. Sometimes there are two levels of values:

  • Base level: normal values
  • Meta level: special values

In this blog post, we examine how we can add special values to base-level types.

Adding special values in band  

One way of adding special values is to create a new type which is a superset of the base type where some values are special. These special values are called sentinels. They exist in band (think inside the same channel), as siblings of normal values.

As an example, consider the following interface for readable streams:

interface InputStream {
  getNextLine(): string;
}

At the moment, .getNextLine() only handles text lines, but not ends of files (EOFs). How could we add support for EOF?

Possibilities include:

  • An additional method .isEof() that needs to be called before calling .getNextLine().
  • .getNextLine() throws an exception when it reaches an EOF.
  • A sentinel value for EOF.

The next two subsections describe two ways in which we can introduce sentinel values.

Adding null or undefined to a type  

When using strict TypeScript, no simple object type (defined via interfaces, object patterns, classes, etc.) includes null. That makes it a good sentinel value that we can add to the base type string via a type union:

type StreamValue = null | string;

interface InputStream {
  getNextLine(): StreamValue;
}

Now, whenever we are using the value returned by .getNextLine(), TypeScript forces us to consider both possibilities: strings and null – for example:

function countComments(is: InputStream) {
  let commentCount = 0;
  while (true) {
    const line = is.getNextLine();
    //@ts-ignore: Object is possibly 'null'.(2531)
    if (line.startsWith('#')) { // (A)
      commentCount++;
    }
    if (line === null) break;
  }
  return commentCount;
}

In line A, we can’t use the string method .startsWith() because line might be null. We can fix this as follows:

function countComments(is: InputStream) {
  let commentCount = 0;
  while (true) {
    const line = is.getNextLine();
    if (line === null) break;
    if (line.startsWith('#')) { // (A)
      commentCount++;
    }
  }
  return commentCount;
}

Now, when execution reaches line A, we can be sure that line is not null.

Adding a symbol to a type  

We can also use values other than null as sentinels. Symbols and objects are best suited for this task because each one of them has a unique identity and no other value can be mistaken for it.

This is how to use a symbol to represent EOF:

const EOF = Symbol('EOF');
type StreamValue = typeof EOF | string;

Note that we need the typeof operator here. TypeScript distinguishes values and types:

  • EOF is a value.
  • The first operand of the union type operator | must be a type.

Aside: literal types  

As an aside, literal types only look like values, but they are actually types – for example:

type StreamValue = 123 | string;

123 looks like a value, but is a type (the type whose only member is the number 123). We can see that if we refer to 123 via a constant:

const EOF = 123;
type StreamValue = typeof EOF | string;

Adding special values out of band  

What do we do if potentially any value can be returned by a method? How do we ensure that base values and meta values don’t get mixed up? This is an example where that might happen:

interface InputStream<T> {
  getNextValue(): T;
}

Whatever special value for EOF we come up with, it is always possible to use typeof EOF for the type parameter T.

The solution is to encode normal values and special values differently, so that they exist separately and can’t be mixed up. This kind of separate existence is called out of band (think in a different channel).

Discriminated unions  

A discriminated union is a type union over several object types that all have at least one property in common. That property must have a different value for each object type – we can think of it as the ID of the object type. In the following example, InputStreamValue<T> is a discriminated.

interface NormalValue<T> {
  type: 'normal';
  data: T;
}
interface Eof {
  type: 'eof';
}
type InputStreamValue<T> = Eof | NormalValue<T>;

interface InputStream<T> {
  getNextValue(): InputStreamValue<T>;
}
function countValues<T>(is: InputStream<T>, data: T) {
  let valueCount = 0;
  while (true) {
    const value = is.getNextValue();
    if (value.type === 'eof') break; // (A)
    if (value.data === data) { // (B)
      valueCount++;
    }
  }
  return valueCount;
}

Due to the check in line A, we can access the property .data in line B, even though that property is specific to type NormalValue.

Iterator results  

When deciding how to implement iterators, TC39 also couldn’t use a fixed sentinel value. Otherwise, that value could appear in iterables and break code. One solution would have been to pick a sentinel value when starting an iteration. TC39 instead opted for a discriminated union with the common property .done:

interface IteratorYieldResult<TYield> {
  done?: false;
  value: TYield;
}

interface IteratorReturnResult<TReturn> {
  done: true;
  value: TReturn;
}

type IteratorResult<T, TReturn = any> =
  | IteratorYieldResult<T>
  | IteratorReturnResult<TReturn>;

Other kinds of type unions  

Other kinds of type unions can be as convenient as discriminated unions, as long as we have the means to distinguish the members of the union.

One possibility is distinguish via unique properties:

interface A {
  one: number;
  two: number;
}
interface B {
  three: number;
  four: number;
}
type Union = A | B;

function func(x: Union) {
  //@ts-ignore: Property 'two' does not exist on type 'Union'.
  //  Property 'two' does not exist on type 'B'.(2339)
  console.log(x.two);
  if ('one' in x) {
    console.log(x.two); // OK
  }
}

Another possibility is distinguish via typeof and/or instance checks:

type Union = [string] | number;

function logHexValue(x: Union) {
  if (Array.isArray(x)) {
    console.log(x[0]); // OK
  } else {
    console.log(x.toString(16)); // OK
  }
}