One way of understanding types is as sets of values. Sometimes there are two levels of values:
In this blog post, 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 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
.
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.|
must be a type.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;
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).
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
.
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 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
}
}