In TypeScript, a value can have a type that is too general for some operations – for example, a union type. This blog post answers the following questions:
To see how a static type can be too general, consider the following function getScore()
:
assert.equal(
getScore('*****'), 5);
assert.equal(
getScore(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
The 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;
return value.length;
}
throw new Error('Unsupported value: ' + value);
}
In this blog post, we interpret types as sets of values. (For more information on this interpretation and another one, see “What is a type in TypeScript? Two perspectives”.)
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
Narrowing also works if we use switch
instead of if
:
function getScore(value: number|string): number {
switch (typeof value) {
case 'number':
// %inferred-type: number
value;
return value;
case 'string':
// %inferred-type: string
value;
return value.length;
default:
throw new Error('Unsupported value: ' + value);
}
}
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) {}
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 {
const result: unknown = JSON.parse(stringLiteral);
if (typeof result === 'string') { // (A)
return result;
}
throw new Error('Not a string literal: ' + stringLiteral);
}
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;
}
}
We can also use ===
to differentiate between the components of a union type:
interface Book {
title: null | string;
isbn: string;
}
function getTitle(book: Book) {
if (book.title !== null) {
// %inferred-type: string
book.title;
return book.title;
} else {
// %inferred-type: null
book.title;
return '(Untitled)';
}
}
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-ignore: 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 types Alas, in
only helps us with union types:
function func(obj: object) {
if ('name' in obj) {
// %inferred-type: object
obj;
// @ts-ignore: Property 'name' does not exist on type 'object'.
obj.name;
}
}
In a discriminated union, the components of a union type have one or more properties in common whose values are different for each component. As a consequence, checking the value of one of those properties 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) {
case 'Teacher':
// %inferred-type: { kind: "Teacher"; teacherId: string; }
attendee;
return attendee.teacherId;
case 'Student':
// %inferred-type: { kind: "Student"; studentId: string; }
attendee;
return attendee.studentId;
default:
throw new Error();
}
}
In the previous example, .kind
is a so-called discriminating property: All components of the union type Attendee
have it and it has a different value in each one.
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;
return attendee.teacherId;
} else if (attendee.kind === 'Student') {
// %inferred-type: { kind: "Student"; studentId: string; }
attendee;
return attendee.studentId;
} else {
throw new Error();
}
}
We can also narrow the types of properties (even of nested ones that we access via chains of property names):
type MyType = {
prop?: number | string,
};
function func(arg: MyType) {
if (typeof arg.prop === 'string') {
// %inferred-type: string
arg.prop; // (A)
[].forEach((x) => {
// %inferred-type: string | number | undefined
arg.prop; // (B)
});
// %inferred-type: string
arg.prop;
arg = {};
// %inferred-type: string | number | undefined
arg.prop; // (C)
}
}
Let’s take a look at several locations in the previous code:
arg.prop
via a type guard..every()
does not narrow If we use .every()
to check that all Array elements are non-nullish, TypeScript does not narrow the type of mixedValues
(line A):
const mixedValues: ReadonlyArray<undefined|null|number> =
[1, undefined, 2, null];
if (mixedValues.every(isNotNullish)) {
// %inferred-type: readonly (number | null | undefined)[]
mixedValues; // (A)
}
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)
return value !== undefined && value !== null;
}
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):
const mixedValues: ReadonlyArray<undefined|null|number> =
[1, undefined, 2, null];
// %inferred-type: number[]
const numbers = mixedValues.filter(isNotNullish);
(isNotNullish()
is defined in the previous subsection.)
Alas, even here, we must use a type guard function directly – an arrow function with a type guard is not enough:
// %inferred-type: (number | null | undefined)[]
const stillMixed1 = mixedValues.filter(
x => x !== undefined && x !== null);
// %inferred-type: (number | null | undefined)[]
const stillMixed2 = mixedValues.filter(
x => typeof x === 'number');
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 can narrow the type of the actual argument x
to Function
:
function func(arg: unknown) {
if (isFunction(arg)) {
// %inferred-type: Function
arg; // type is narrowed
}
}
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, the type unknown
does not let us make the function call in line A. Therefore, we have to use the type any
for the parameter value
.
isArrayInstanceof()
/**
* 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 produce unsafe code because we could
* subsequently use an alias to push a value into `arr` whose type
* isn’t T.
*/
function isArrayInstanceof<T>(arr: any, Class: new (...args: any[])=>T)
: arr is Array<T> {
// %inferred-type: any
arr;
if (!Array.isArray(arr)) {
return false;
}
// %inferred-type: any[]
arr; // narrowed
if (!arr.every(elem => elem instanceof Class)) {
return false;
}
// %inferred-type: any[]
arr; // not narrowed
return true;
}
This is how this function is used:
const value: unknown = {};
if (isArrayInstanceof(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: any, prim: T): value is T {
if (prim === null) {
return value === null;
}
return value !== null && (typeof prim) === (typeof value);
}
Because it is difficult to turn an expected result of typeof
(such as 'boolean'
or 'number'
) into a type T
, we opted to specify T
via an example value. This is not ideal, but it works:
const value: unknown = {};
if (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 {
return typeof value === typeString;
}
const value: unknown = {};
if (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] {
return typeof value === typeString;
}
const value: unknown = {};
if (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) {
const dotIndex = filename.lastIndexOf('.');
assert(dotIndex >= 0); // (A)
return filename.slice(0, dotIndex);
}
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) {
throw new Error();
}
}
This is how assertTrue()
causes narrowing:
function func(value: unknown) {
assertTrue(value instanceof Set); // (A)
// %inferred-type: Set<any>
value; // (B)
}
The invocation of the assertion function assertTrue()
in line A influenced the static type of value
in line B (in a manner similar to type guards).
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') {
throw new TypeError();
}
}
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...
Object.assign(obj, {x, y});
}
const obj = { color: 'green' };
addXY(obj, 9, 4);
// %inferred-type: { color: string; } & { x: number; y: number; }
obj;
An intersection type S & T
produces a type that has the properties of both S
and T
.
function isString(value: unknown): value is string {
return typeof value === 'string';
}
value is string
boolean
asserts «cond»
function assertTrue(condition: boolean): asserts condition {
if (!condition) {
throw new Error(); // assertion error
}
}
asserts condition
void
, exceptionasserts «arg» is «type»
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error(); // assertion error
}
}
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') {
return value;
}
throw new TypeError();
}
const value1a: unknown = 123;
// %inferred-type: number
const value1b = forceNumber(value1a);
const value2: unknown = 'abc';
assert.throws(() => forceNumber(value2));
The corresponding assertion function looks as follows:
function assertIsNumber(value: unknown): asserts value is number {
if (typeof value !== 'number') {
throw new TypeError();
}
}
const value1: unknown = 123;
assertIsNumber(value1);
// %inferred-type: number
value1;
const value2: unknown = 'abc';
assert.throws(() => assertIsNumber(value2));
Forced conversion is a versatile technique with uses beyond being an alternative to assertion functions. For example, you can convert an input format (think JSON schema) that is easy to write into an output format that is easy to work with in code.
Consider the following code:
function getLengthOfValue(strMap: Map<string, string>, key: string)
: number {
if (strMap.has(key)) {
const value = strMap.get(key);
// %inferred-type: string | undefined
value; // before throwing an exception
// We know that value can’t be `undefined`
if (value === undefined) { // (A)
throw new Error();
}
// %inferred-type: string
value; // after throwing an exception
return value.length;
}
return -1;
}
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 guards The library @hqoss/guards
provides a collection of type guards for TypeScript – for example:
isBoolean()
, isNumber()
, etc.isObject()
, isNull()
, isFunction()
, etc.isNonEmptyArray()
, isInteger()
, etc.