Tackling TypeScript
Please support this book: buy it or donate
(Ad, please don’t block.)

14 Adding special values to types



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

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

14.1 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:

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

14.1.1 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 union type:

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-expect-error: 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.

14.1.2 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;

Why do we need typeof and can’t use EOF directly? That’s because EOF is a value, not a type. The type operator typeof converts EOF to a type. For more information on the different language levels of values and types, see §7.7 “The two language levels: dynamic vs. static”.

14.2 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 value we pick for EOF, there is a risk of someone creating an InputStream<typeof EOF> and adding that value to the stream.

The solution is to keep normal values and special values separate, so that they can’t be mixed up. Special values existing separately is called out of band (think different channel).

14.2.1 Discriminated unions

A discriminated union is a union type over several object types that all have at least one property in common, the so-called discriminant. The discriminant must have a different value for each object type – we can think of it as the ID of the object type.

14.2.1.1 Example: InputStreamValue

In the following example, InputStreamValue<T> is a discriminated union and its discriminant is .type.

interface NormalValue<T> {
  type: 'normal'; // string literal type
  data: T;
}
interface Eof {
  type: 'eof'; // string literal type
}
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) {
    // %inferred-type: Eof | NormalValue<T>
    const value = is.getNextValue(); // (A)

    if (value.type === 'eof') break;

    // %inferred-type: NormalValue<T>
    value; // (B)

    if (value.data === data) { // (C)
      valueCount++;
    }
  }
  return valueCount;
}

Initially, the type of value is InputStreamValue<T> (line A). Then we exclude the value 'eof' for the discriminant .type and its type is narrowed to NormalValue<T> (line B). That’s why we can access property .data in line C.

14.2.1.2 Example: IteratorResult

When deciding how to implement iterators, TC39 didn’t want to 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; // boolean literal type
  value: TYield;
}

interface IteratorReturnResult<TReturn> {
  done: true; // boolean literal type
  value: TReturn;
}

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

14.2.2 Other kinds of union types

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

One possibility is to distinguish the member types via unique properties:

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

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

Another possibility is to distinguish the member types via typeof and/or instance checks:

type Union = [string] | number;

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