null
or undefined
to a typeOne 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.
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:
.isEof()
that needs to be called before calling .getNextLine()
..getNextLine()
throws an exception when it reaches an EOF.The next two subsections describe two ways in which we can introduce sentinel values.
null
or undefined
to a typeWhen 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) {
= 0;
let commentCount while (true) {
= is.getNextLine();
const line // @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) {
= 0;
let commentCount while (true) {
= is.getNextLine();
const line 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
.
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:
= Symbol('EOF');
const 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”.
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:
<T> {
interface InputStreamgetNextValue(): 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).
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.
InputStreamValue
In the following example, InputStreamValue<T>
is a discriminated union and its discriminant is .type
.
<T> {
interface NormalValue: 'normal'; // string literal type
type: T;
data
}
interface Eof {: 'eof'; // string literal type
type
}type InputStreamValue<T> = Eof | NormalValue<T>;
<T> {
interface InputStreamgetNextValue(): InputStreamValue<T>;
}
function countValues<T>(is: InputStream<T>, data: T) {
= 0;
let valueCount while (true) {
// %inferred-type: Eof | NormalValue<T>
= is.getNextValue(); // (A)
const value
if (value.type === 'eof') break;
// %inferred-type: NormalValue<T>
; // (B)
value
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.
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
:
<TYield> {
interface IteratorYieldResult?: false; // boolean literal type
done: TYield;
value
}
<TReturn> {
interface IteratorReturnResult: true; // boolean literal type
done: TReturn;
value
}
type IteratorResult<T, TReturn = any> =
| IteratorYieldResult<T>
| IteratorReturnResult<TReturn>;
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 {: number;
one: number;
two
}
interface B {: number;
three: number;
four
}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
} }