if
and type guardsswitch
and a type guardunknown
@hqoss/guards
: library with type guardsIn TypeScript, a value can have a type that is too general for some operations – for example, a union type. This chapter answers the following questions:
T
of a storage location (such as a variable or a property) to a subset of T
. For example, it is often useful to narrow the type null|string
to the type string
.typeof
and instanceof
are type guards.To see how a static type can be too general, consider the following function getScore()
:
.equal(
assertgetScore('*****'), 5);
.equal(
assertgetScore(3), 3);
The skeleton of getScore()
looks as follows:
function getScore(value: number|string): number {
// ···
}
Inside the body of getScore()
, we don’t know if the type of value
number
or string
. Before we do, we can’t really work with value
.
if
and type guardsThe solution is to check the type of value
at runtime, via typeof
(line A and line B):
function getScore(value: number|string): number {
if (typeof value === 'number') { // (A)
// %inferred-type: number
;
value;
return value
}if (typeof value === 'string') { // (B)
// %inferred-type: string
;
value.length;
return value
}new Error('Unsupported value: ' + value);
throw }
In this chapter, we interpret types as sets of values. (For more information on this interpretation and another one, see [content not included].)
Inside the then-blocks starting in line A and line B, the static type of value
changes, due to the checks we performed. We are now working with subsets of the original type number|string
. This way of reducing the size of a type is called narrowing. Checking the result of typeof
and similar runtime operations are called type guards.
Note that narrowing does not change the original type of value
, it only makes it more specific as we pass more checks.
switch
and a type guardNarrowing also works if we use switch
instead of if
:
function getScore(value: number|string): number {
switch (typeof value) {
'number':
case // %inferred-type: number
;
value;
return value'string':
case // %inferred-type: string
;
value.length;
return valuedefault:
new Error('Unsupported value: ' + value);
throw
} }
These are more examples of types being too general:
Nullable types:
function func1(arg: null|string) {}
function func2(arg: undefined|string) {}
Discriminated unions:
type Teacher = { kind: 'Teacher', teacherId: string };
type Student = { kind: 'Student', studentId: string };
type Attendee = Teacher | Student;
function func3(attendee: Attendee) {}
Types of optional parameters:
function func4(arg?: string) {
// %inferred-type: string | undefined
;
arg }
Note that these types are all union types!
unknown
If a value has the type unknown
, we can do almost nothing with it and have to narrow its type first (line A):
function parseStringLiteral(stringLiteral: string): string {
: unknown = JSON.parse(stringLiteral);
const resultif (typeof result === 'string') { // (A)
;
return result
}new Error('Not a string literal: ' + stringLiteral);
throw }
In other words: The type unknown
is too general and we must narrow it. In a way, unknown
is also a union type (the union of all types).
As we have seen, a type guard is an operation that returns either true
or false
– depending on whether its operand meets certain criteria at runtime. TypeScript’s type inference supports type guards by narrowing the static type of an operand when the result is true
.
===
)Strict equality works as a type guard:
function func(value: unknown) {
if (value === 'abc') {
// %inferred-type: "abc"
;
value
} }
For some union types, we can use ===
to differentiate between their components:
interface Book {: null | string;
title: string;
isbn
}
function getTitle(book: Book) {
if (book.title === null) {
// %inferred-type: null
.title;
book'(Untitled)';
return
} else {// %inferred-type: string
.title;
book.title;
return book
} }
Using ===
for including and !===
for excluding a union type component only works if that component is a singleton type (a set with one member). The type null
is a singleton type. Its only member is the value null
.
typeof
, instanceof
, Array.isArray
These are three common built-in type guards:
function func(value: Function|Date|number[]) {
if (typeof value === 'function') {
// %inferred-type: Function
;
value
}
if (value instanceof Date) {
// %inferred-type: Date
;
value
}
if (Array.isArray(value)) {
// %inferred-type: number[]
;
value
} }
Note how the static type of value
is narrowed inside the then-blocks.
in
If used to check for distinct properties, the operator in
is a type guard:
type FirstOrSecond =
| {first: string}
| {second: string};
function func(firstOrSecond: FirstOrSecond) {
if ('second' in firstOrSecond) {
// %inferred-type: { second: string; }
;
firstOrSecond
} }
Note that the following check would not have worked:
function func(firstOrSecond: FirstOrSecond) {
// @ts-expect-error: Property 'second' does not exist on
// type 'FirstOrSecond'. [...]
if (firstOrSecond.second !== undefined) {
// ···
} }
The problem in this case is that, without narrowing, we can’t access property .second
of a value whose type is FirstOrSecond
.
in
doesn’t narrow non-union typesAlas, in
only helps us with union types:
function func(obj: object) {
if ('name' in obj) {
// %inferred-type: object
;
obj
// @ts-expect-error: Property 'name' does not exist on type 'object'.
.name;
obj
} }
In a discriminated union, the components of a union type have one or more properties in common whose values are different for each component. Such properties are called discriminants.
Checking the value of a discriminant is a type guard:
type Teacher = { kind: 'Teacher', teacherId: string };
type Student = { kind: 'Student', studentId: string };
type Attendee = Teacher | Student;
function getId(attendee: Attendee) {
switch (attendee.kind) {
'Teacher':
case // %inferred-type: { kind: "Teacher"; teacherId: string; }
;
attendee.teacherId;
return attendee'Student':
case // %inferred-type: { kind: "Student"; studentId: string; }
;
attendee.studentId;
return attendeedefault:
new Error();
throw
} }
In the previous example, .kind
is a discriminant: Each components of the union type Attendee
has this property, with a unique value.
An if
statement and equality checks work similarly to a switch
statement:
function getId(attendee: Attendee) {
if (attendee.kind === 'Teacher') {
// %inferred-type: { kind: "Teacher"; teacherId: string; }
;
attendee.teacherId;
return attendeeif (attendee.kind === 'Student') {
} else // %inferred-type: { kind: "Student"; studentId: string; }
;
attendee.studentId;
return attendee
} else {new Error();
throw
} }
We can also narrow the types of properties (even of nested ones that we access via chains of property names):
type MyType = {
?: number | string,
prop;
}function func(arg: MyType) {
if (typeof arg.prop === 'string') {
// %inferred-type: string
.prop; // (A)
arg
.forEach((x) => {
[]// %inferred-type: string | number | undefined
.prop; // (B)
arg;
})
// %inferred-type: string
.prop;
arg
= {};
arg
// %inferred-type: string | number | undefined
.prop; // (C)
arg
} }
Let’s take a look at several locations in the previous code:
arg.prop
via a type guard..every()
does not narrowIf we use .every()
to check that all Array elements are non-nullish, TypeScript does not narrow the type of mixedValues
(line A):
: ReadonlyArray<undefined|null|number> =
const mixedValues1, undefined, 2, null];
[
if (mixedValues.every(isNotNullish)) {
// %inferred-type: readonly (number | null | undefined)[]
; // (A)
mixedValues }
Note that mixedValues
has to be read-only. If it weren’t, another reference to it would statically allow us to push null
into mixedValues
inside the if
statement. But that renders the narrowed type of mixedValues
incorrect.
The previous code uses the following user-defined type guard (more on what that is soon):
function isNotNullish<T>(value: T): value is NonNullable<T> { // (A)
!== undefined && value !== null;
return value }
NonNullable<Union>
(line A) is a utility type that removes the types undefined
and null
from union type Union
.
.filter()
produces Arrays with narrower types.filter()
produces Arrays that have narrower types (i.e., it doesn’t really narrow existing types):
// %inferred-type: (number | null | undefined)[]
= [1, undefined, 2, null];
const mixedValues
// %inferred-type: number[]
= mixedValues.filter(isNotNullish);
const numbers
function isNotNullish<T>(value: T): value is NonNullable<T> { // (A)
!== undefined && value !== null;
return value }
Alas, we must use a type guard function directly – an arrow function with a type guard is not enough:
// %inferred-type: (number | null | undefined)[]
= mixedValues.filter(
const stillMixed1 => x !== undefined && x !== null);
x
// %inferred-type: (number | null | undefined)[]
= mixedValues.filter(
const stillMixed2 => typeof x === 'number'); x
TypeScript lets us define our own type guards – for example:
function isFunction(value: unknown): value is Function {
return typeof value === 'function';
}
The return type value is Function
is a type predicate. It is part of the type signature of isFunction()
:
// %inferred-type: (value: unknown) => value is Function
; isFunction
A user-defined type guard must always return booleans. If isFunction(x)
returns true
, TypeScript narrows the type of the actual argument x
to Function
:
function func(arg: unknown) {
if (isFunction(arg)) {
// %inferred-type: Function
; // type is narrowed
arg
} }
Note that TypeScript doesn’t care how we compute the result of a user-defined type guard. That gives us a lot of freedom w.r.t. the checks we use. For example, we could have implemented isFunction()
as follows:
function isFunction(value: any): value is Function {
try {value(); // (A)
;
return true
} catch {;
return false
} }
Alas, we have to use the type any
for the parameter value
because the type unknown
does not let us make the function call in line A.
isArrayWithInstancesOf()
/**
* This type guard for Arrays works similarly to `Array.isArray()`,
* but also checks if all Array elements are instances of `T`.
* As a consequence, the type of `arr` is narrowed to `Array<T>`
* if this function returns `true`.
*
* Warning: This type guard can make code unsafe – for example:
* We could use another reference to `arr` to add an element whose
* type is not `T`. Then `arr` doesn’t have the type `Array<T>`
* anymore.
*/
function isArrayWithInstancesOf<T>(
: any, Class: new (...args: any[])=>T)
arr: arr is Array<T>
{if (!Array.isArray(arr)) {
;
return false
}if (!arr.every(elem => elem instanceof Class)) {
;
return false
}
// %inferred-type: any[]
; // (A)
arr
;
return true }
In line A, we can see that the inferred type of arr
is not Array<T>
, but our checks have ensured that it currently is. That’s why we can return true
. TypeScript trusts us and narrows to Array<T>
when we use isArrayWithInstancesOf()
:
: unknown = {};
const valueif (isArrayWithInstancesOf(value, RegExp)) {
// %inferred-type: RegExp[]
;
value }
isTypeof()
This is a first attempt to implement typeof
in TypeScript:
/**
* An implementation of the `typeof` operator.
*/
function isTypeof<T>(value: unknown, prim: T): value is T {
if (prim === null) {
=== null;
return value
}!== null && (typeof prim) === (typeof value);
return value }
Ideally, we’d be able to specify the expected type of value
via a string (i.e., one of the results of typeof
). But then we would have to derive the type T
from that string and it’s not immediately obvious how to do that (there is a way, as we’ll see soon). As a workaround, we specify T
via a member prim
of T
:
: unknown = {};
const valueif (isTypeof(value, 123)) {
// %inferred-type: number
;
value }
A better solution is to use overloading (several cases are omitted):
/**
* A partial implementation of the `typeof` operator.
*/
function isTypeof(value: any, typeString: 'boolean'): value is boolean;
function isTypeof(value: any, typeString: 'number'): value is number;
function isTypeof(value: any, typeString: 'string'): value is string;
function isTypeof(value: any, typeString: string): boolean {
=== typeString;
return typeof value
}
: unknown = {};
const valueif (isTypeof(value, 'boolean')) {
// %inferred-type: boolean
;
value }
(This approach is an idea by Nick Fisher.)
An alternative is to use an interface as a map from strings to types (several cases are omitted):
interface TypeMap {: boolean;
boolean: number;
number: string;
string
}
/**
* A partial implementation of the `typeof` operator.
*/
function isTypeof<T extends keyof TypeMap>(value: any, typeString: T)
: value is TypeMap[T] {
=== typeString;
return typeof value
}
: unknown = {};
const valueif (isTypeof(value, 'string')) {
// %inferred-type: string
;
value }
(This approach is an idea by Ran Lottem.)
An assertion function checks if its parameter fulfills certain criteria and throws an exception if it doesn’t. For example, one assertion function supported by many languages, is assert()
. assert(cond)
throws an exception if the boolean condition cond
is false
.
On Node.js, assert()
is supported via the built-in module assert
. The following code uses it in line A:
import assert from 'assert';
function removeFilenameExtension(filename: string) {
= filename.lastIndexOf('.');
const dotIndex assert(dotIndex >= 0); // (A)
.slice(0, dotIndex);
return filename }
TypeScript’s type inference provides special support for assertion functions, if we mark such functions with assertion signatures as return types. W.r.t. how and what we can return from a function, an assertion signature is equivalent to void
. However, it additionally triggers narrowing.
There are two kinds of assertion signatures:
asserts «cond»
asserts «arg» is «type»
asserts «cond»
In the following example, the assertion signature asserts condition
states that the parameter condition
must be true
. Otherwise, an exception is thrown.
function assertTrue(condition: boolean): asserts condition {
if (!condition) {
new Error();
throw
} }
This is how assertTrue()
causes narrowing:
function func(value: unknown) {
assertTrue(value instanceof Set);
// %inferred-type: Set<any>
;
value }
We are using the argument value instanceof Set
similarly to a type guard, but instead of skipping part of a conditional statement, false
triggers an exception.
asserts «arg» is «type»
In the following example, the assertion signature asserts value is number
states that the parameter value
must have the type number
. Otherwise, an exception is thrown.
function assertIsNumber(value: any): asserts value is number {
if (typeof value !== 'number') {
new TypeError();
throw
} }
This time, calling the assertion function, narrows the type of its argument:
function func(value: unknown) {
assertIsNumber(value);
// %inferred-type: number
;
value }
The function addXY()
adds properties to existing objects and updates their types accordingly:
function addXY<T>(obj: T, x: number, y: number)
: asserts obj is (T & { x: number, y: number }) {
// Adding properties via = would be more complicated...
.assign(obj, {x, y});
Object
}
= { color: 'green' };
const obj addXY(obj, 9, 4);
// %inferred-type: { color: string; } & { x: number; y: number; }
; obj
An intersection type S & T
has the properties of both type S
and type T
.
function isString(value: unknown): value is string {
=== 'string';
return typeof value }
value is string
boolean
asserts «cond»
function assertTrue(condition: boolean): asserts condition {
if (!condition) {
new Error(); // assertion error
throw
} }
asserts condition
void
, exceptionasserts «arg» is «type»
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
new Error(); // assertion error
throw
} }
asserts value is string
void
, exceptionAn assertion function narrows the type of an existing value. A forced conversion function returns an existing value with a new type – for example:
function forceNumber(value: unknown): number {
if (typeof value !== 'number') {
new TypeError();
throw
};
return value
}
: unknown = 123;
const value1a// %inferred-type: number
= forceNumber(value1a);
const value1b
: unknown = 'abc';
const value2.throws(() => forceNumber(value2)); assert
The corresponding assertion function looks as follows:
function assertIsNumber(value: unknown): asserts value is number {
if (typeof value !== 'number') {
new TypeError();
throw
}
}
: unknown = 123;
const value1assertIsNumber(value1);
// %inferred-type: number
;
value1
: unknown = 'abc';
const value2.throws(() => assertIsNumber(value2)); assert
Forced conversion is a versatile technique with uses beyond those of assertion functions. For example, we can convert:
For more information, see [content not included].
Consider the following code:
function getLengthOfValue(strMap: Map<string, string>, key: string)
: number {
if (strMap.has(key)) {
= strMap.get(key);
const value
// %inferred-type: string | undefined
; // before type check
value
// We know that value can’t be `undefined`
if (value === undefined) { // (A)
new Error();
throw
}
// %inferred-type: string
; // after type check
value
.length;
return value
}-1;
return }
Instead of the if
statement that starts in line A, we also could have used an assertion function:
assertNotUndefined(value);
Throwing an exception is a quick alternative if we don’t want to write such a function. Similarly to calling an assertion function, this technique also updates the static type.
@hqoss/guards
: library with type guardsThe library @hqoss/guards
provides a collection of type guards for TypeScript – for example:
isBoolean()
, isNumber()
, etc.isObject()
, isNull()
, isFunction()
, etc.isNonEmptyArray()
, isInteger()
, etc.