This chapter explains the basics of TypeScript. After reading it, you should be able to write your first TypeScript code. My hope is that that shouldn’t take you longer than a day. I’d love to hear how long it actually took you – my guess may be off.
Start reading here
You can start reading this book with this chapter: No prior knowledge is required other than JavaScript. Alternatively, if you first want to get a better understanding of how TypeScript fits into development workflows as a tool, you can check out “How TypeScript is used: workflows, tools, etc.” (§6).
After reading this chapter, you should be able to understand the following TypeScript code (which we’ll get back to at the end):
interface Array<T> {
concat(...items: Array<T[] | T>): T[];
reduce<U>(
callback: (state: U, element: T, index: number) => U,
firstState?: U
): U;
// ···
}
You may think that this is cryptic. And I agree with you! But (as I hope to prove) this syntax is relatively easy to learn. And once you understand it, it gives you immediate, precise and comprehensive summaries of how code behaves – without having to read long descriptions in English.
This chapter is meant to be consumed passively: Everything you need to see is shown here, including explorations of what a piece of code does.
However, you may still want to play with TypeScript code. The following chapter explains how to do that: “Trying out TypeScript without installing it” (§7).
In this chapter:
boolean
is a set whose elements are false
and true
.
S
being a subtype of T
means that S
is a subset of T
.
TypeScript is JavaScript plus syntax for adding static type information. Therefore, TypeScript has two language levels – two ways of using source code:
Program level | Type level | |
---|---|---|
Programming language is | JavaScript | TypeScript |
Source code is | executed | type-checked |
Types are | dynamic | static |
Types exist at | runtime | compile time |
So far, we have only talked about TypeScript’s (static) types. But JavaScript also has types:
> typeof true
'boolean'
Its types are called dynamic. Why is that? We have to run code to see if they are used correctly – e.g.:
const value = null;
assert.throws(
() => value.length,
/^TypeError: Cannot read properties of null/
);
In contrast, TypeScript’s types are static: We check them by analyzing the syntax – without running the code. That happens during editing (for individual files) or when running the TypeScript compiler tsc
(for the whole code base). In the following code, TypeScript detects the error via type checking (note that it doesn’t even need explicit type information in this case):
const value = null;
// @ts-expect-error: 'value' is possibly 'null'.
value.length;
@ts-expect-error
shows type checking errors
In this book, type checking errors are shown via @ts-expect-error
directives (more information).
The JavaScript language (not TypeScript!) has only eight types. In the ECMAScript specification, they have names that start with capital letters. Here, I’m going with the values returned by typeof
– e.g.:
> typeof undefined
'undefined'
> typeof 123
'number'
> typeof 'abc'
'string'
JavaScript’s eight types are:
undefined
: the set with the only element undefined
null
: the set with the only element null
. Due to a historical bad decision, typeof
returns 'object'
for the value null
and not 'null'
.
boolean
: the set with the two elements false
and true
number
: the set of all numbers
bigint
: the set of all arbitrary-precision integers
string
: the set of all strings
symbol
: the set of all symbols
object
: the set of all objects (which includes functions and Arrays)
typeof
additionally has a separate “type” for functions but that is not how ECMAScript sees things internally.
All of these types are dynamic. They can also be used at the type level in TypeScript (see next section).
TypeScript brings an additional layer to JavaScript: static types. In source code, there are:
128
, true
or ['a', 'b']
Both have static types in TypeScript:
One way in which a storage location such as a variable can receive a static type is via a type annotation – e.g.:
let count: number;
The colon (:
) plus the type number
is the type annotation. It states that the static type of the variable count
is number
. The type annotation helps with type checking:
let count: number;
// @ts-expect-error: Type 'string' is not assignable to type 'number'.
count = 'yes';
What does the error message mean? The (implicit) static type string
of the data source 'yes'
is incompatible with the (explicitly specified) static type number
of the data sink count
.
The next example shows a function with type annotations:
function toString(num: number): string {
return String(num);
}
There are two type annotations:
num
has the type number
.
string
.
Let’s briefly revisit the two language levels. It’s interesting to see how they show up in TypeScript’s syntax:
const noValue: undefined = undefined;
At the dynamic level, we use JavaScript to declare a variable noValue
and initialize it with the value undefined
.
At the static level, we use TypeScript to specify that variable noValue
has the static type undefined
.
The same syntax, undefined
, is used at the JavaScript level and at the type level and means different things – depending on where it is used.
Several primitive types have so-called literal types:
let thousand: 1000 = 1000;
The 1000
after the colon is a type, a number literal type: It is a set whose only element is the value 1000
and it is a subtype of number
.
On one hand, any value we assign to thousand
must be 1000
:
thousand = 1000; // OK
// @ts-expect-error: Type '999' is not assignable to type '1000'.
thousand = 999;
On the other hand, we can assign thousand
to any variable whose type is number
because its type is a subtype of number
:
const num: number = thousand;
Except for symbol
, all primitive types have literal types:
// boolean literal type
const TRUTHY: true = true;
// bigint literal type
const HUNDRED: 100n = 100n;
// string literal type
const YES: 'yes' = 'yes';
// These could also be considered literal types
const UNDEF: undefined = undefined;
const NULL: null = null;
Especially string literal types will become useful later (when we get to union types).
any
, unknown
and never
TypeScript has several types that are specific to the type level:
any
is a wildcard type and accepts any value (see below).
unknown
is similar to any
but less flexible: If a variable or parameter has that type, we can also write any value to it. However, we can’t do anything with its content unless we perform further type checks. Being less flexible is a good thing: I recommend avoiding any
and instead using unknown
whenever possible. For more information see “The top types any
and unknown
” (§14).
never
the empty set as a type. Among other things, it is used for locations that are never reached when a program is executed.
any
If the type of a storage location is neither explicitly specified nor inferrable, TypeScript uses the type any
for it. any
is the type of all values and a wildcard type: If a value has that type, TypeScript does not limit us in any way.
If strict
type checking is enabled, we can only use any
explicitly: Every location must have an explicit or inferred static type. That is safer because there are no holes in type checking, no unintended blind spots.
Let’s look at examples – the type of parameters can usually not be inferred:
// @ts-expect-error: Parameter 'arg' implicitly has an 'any' type.
function func1(arg): void {} // error
function func2(arg: boolean): void {} // OK
function func3(arg = false): void {} // OK
For func3
, TypeScript can infer that arg
has the type boolean
because it has the default value false
.
In many cases, TypeScript can automatically derive the types of data sources or data sinks, without us having to annotate anything. That is called type inference.
This is an example of type inference:
const count = 14;
assertType<14>(count);
assertType<T>(v)
shows the type T
of a value v
In this book, assertType<T>(v)
is used to show that a value v
has the type T
– which was either inferred or explicitly assigned. For more information see “Type level: assertType<T>(v)
” (§5.2).
TypeScript infers that the type of count
is 14
. It can do so because it knows that the value 14
has the type 14
. Interestingly, TypeScript infers a more general type when we use let
:
let count = 14;
assertType<number>(count);
Why is that? The assumption is that the value of count
is preliminary and that we want to assign other (similar!) values later on. If count
had the type 14
then we wouldn’t be able to do that.
Another example of type inference: In this case TypeScript infers that function toString()
has the return type string
.
function toString(num: number) {
return String(num);
}
assertType<string>(toString(32));
Type inference is not guesswork: It follows clear rules (similar to arithmetic) for deriving types where they haven’t been specified explicitly. For example:
const strValue = String(32);
assertType<string>(strValue);
The inferred type of strValue
is string
:
Step 1: The inferred type of 32
is 32
.
Step 2: String
used as a function has the following type (simplified):
(value: any) => string
This type notation is used for functions and means:
value
. That parameter has the type any
. If a parameter has that type, it accepts any kind of value. (More on any
soon.)
string
.
Step 3: By combining the results of step 1 and step 2, TypeScript can infer that strValue
has the type string
.
With type
we can create a new name (an alias) for an existing type:
type Age = number;
const age: Age = 82;
Compound types have other types inside them – which makes them very expressive. These are a few examples:
// Array types
type StringArray = Array<string>;
// Function types
type NumToStr = (num: number) => string;
// Object literal types
type BlogPost = {
title: string,
tags: Array<string>,
};
// Union types
type YesOrNo = 'yes' | 'no';
Next, we’ll explore all of these compound types and more.
TypeScript has two different ways of typing Arrays:
T[]
or Array<T>
is used if an Array is a collection of values that all have the same type T
.
[T0, T1, ···]
is used if the index of an Array element determines its type.
T[]
and Array<T>
For historical reasons, there are two equivalent ways of expressing the fact that arr
is an Array, used to manage a sequence of numbers (think list, stack, queue, etc.):
let arr1: number[] = [];
let arr2: Array<number> = [];
Normally, TypeScript can infer the type of a variable if there is an assignment. In this case, we have to help it because with an empty Array, it can’t determine the type of the elements.
We’ll explore the angle brackets notation of Array<number>
in more detail later (spoiler: Array
is a generic type and number
is a type parameter).
In JavaScript’s standard library, Object.keys()
returns an array:
const keys = Object.keys({prop: 123});
assertType<string[]>(keys);
[T0, T1, ···]
The following variable entry
has a tuple type:
const entry: [string, number] = ['count', 33];
We can use it to create an object via Object.fromEntries()
:
assert.deepEqual(
Object.fromEntries([entry]),
{
count: 33,
}
);
What is the nature of entry
? At the JavaScript level, it’s also an Array, but it is used differently:
2
.
string
.
number
.
This is an example of a function type:
type NumToStr = (num: number) => string;
This type comprises every function that accepts a single parameter of type number and returns a string. Let’s use this type in a type annotation:
const toString: NumToStr = (num) => String(num);
Because TypeScript knows that toString
has the type NumToStr
, we do not need type annotations inside the arrow function.
We can also define toString
like this:
const toString = (num: number): string => String(num);
Note that we specified both a type for the parameter num
and a return type. The inferred type of toString
is:
assertType<
(num: number) => string
>(toString);
The following function has a parameter callback
whose type is a function:
function stringify123(callback: (num: number) => string): string {
return callback(123);
}
Due to the type of the parameter callback
, TypeScript rejects the following function call:
// @ts-expect-error: Argument of type 'NumberConstructor' is not
// assignable to parameter of type '(num: number) => string'.
stringify123(Number);
But it accepts this function call:
assert.equal(
stringify123(String), '123'
);
We can also use an arrow function to implement stringify123()
:
const stringify123 =
(callback: (num: number) => string): string => callback(123);
TypeScript is good at inferring the return types of functions, but specifying them explicitly is recommended: It makes intentions clearer, enables additional consistency checks and helps external tools with generating declaration files (those tools usually can’t infer return types).
void
void
is a special return type for a function: It tells TypeScript that the function always returns undefined
.
It may do so explicitly:
function f1(): void {
return undefined;
}
Or it may do so implicitly:
function f2(): void {}
However, such a function cannot explicitly return values other than undefined
:
function f3(): void {
// @ts-expect-error: Type 'string' is not assignable to type 'void'.
return 'abc';
}
A question mark after an identifier means that the parameter is optional. For example:
function stringify123(callback?: (num: number) => string) {
if (callback === undefined) {
callback = String;
}
return callback(123); // (A)
}
TypeScript only lets us make the function call in line A if we make sure that callback
isn’t undefined
(which it is if the parameter was omitted).
TypeScript supports parameter default values:
function createPoint(x=0, y=0): [number, number] {
return [x, y];
}
assert.deepEqual(
createPoint(),
[0, 0]);
assert.deepEqual(
createPoint(1, 2),
[1, 2]);
Default values make parameters optional. We can usually omit type annotations, because TypeScript can infer the types. For example, it can infer that x
and y
both have the type number
.
If we wanted to add type annotations, that would look as follows.
function createPoint(x:number = 0, y:number = 0): [number, number] {
return [x, y];
}
We can also use rest parameters in TypeScript parameter definitions. Their static types must be Arrays or tuples:
function joinNumbers(...nums: number[]): string {
return nums.join('-');
}
assert.equal(
joinNumbers(1, 2, 3),
'1-2-3'
);
Similarly to Arrays, objects can be used in two ways in JavaScript (that are occasionally mixed):
Fixed-layout object: A fixed number of properties that are known at development time. Each property can have a different type.
Dictionary object: An arbitrary number of properties whose names are not known at development time. All properties have the same type.
We are ignoring dictionary objects in this chapter – they are covered in “Index signatures: objects as dictionaries” (§18.7). As an aside, Maps are usually a better choice for dictionaries, anyway.
Object literal types describe fixed-layout objects – e.g.:
type Point = {
x: number,
y: number,
};
We can also use semicolons instead of commas to separate members, but the latter are more common.
The members can also be separated by semicolons instead of commas but since the syntax of object literals types is related to the syntax of object literals (where members must be separated by commas), commas are used more often.
Interfaces are mostly equivalent to object literal types but have become less popular over time. This is what an interface looks like:
interface Point {
x: number;
y: number;
} // no semicolon!
The members can also be separated by commas instead of semicolons but since the syntax of interfaces is related to the syntax of classes (where members must be separated by semicolons), semicolons are used more often.
One big advantage of TypeScript’s type system is that it works structurally, not nominally. That is, the type Point
matches all objects that have the appropriate structure:
type Point = {
x: number,
y: number,
};
function pointToString(pt: Point) {
return `(${pt.x}, ${pt.y})`;
}
assert.equal(
pointToString({x: 5, y: 7}), // compatible structure
'(5, 7)');
Conversely, in Java’s nominal type system, we must explicitly declare with each class which interfaces it implements. Therefore, a class can only implement interfaces that exist at its creation time.
If a property can be omitted, we put a question mark after its name:
type Person = {
name: string,
company?: string,
};
In the following example, both john
and jane
match the type Person
:
const john: Person = {
name: 'John',
};
const jane: Person = {
name: 'Jane',
company: 'Massive Dynamic',
};
Object literal types can also contain methods:
type Point = {
x: number,
y: number,
distance(other: Point): number,
};
As far as TypeScript’s type system is concerned, method definitions and properties whose values are functions, are equivalent:
type HasMethodDef = {
simpleMethod(flag: boolean): void,
};
type HasFuncProp = {
simpleMethod: (flag: boolean) => void,
};
type _ = Assert<Equal<
HasMethodDef,
HasFuncProp
>>;
const objWithMethod = {
simpleMethod(flag: boolean): void {},
};
assertType<HasMethodDef>(objWithMethod);
assertType<HasFuncProp>(objWithMethod);
const objWithOrdinaryFunction: HasMethodDef = {
simpleMethod: function (flag: boolean): void {},
};
assertType<HasMethodDef>(objWithOrdinaryFunction);
assertType<HasFuncProp>(objWithOrdinaryFunction);
const objWithArrowFunction: HasMethodDef = {
simpleMethod: (flag: boolean): void => {},
};
assertType<HasMethodDef>(objWithArrowFunction);
assertType<HasFuncProp>(objWithArrowFunction);
My recommendation is to use whichever syntax best expresses how a property should be set up.
The values that are held by a variable (one value at a time) may be members of different types. In that case, we need a union type. For example, in the following code, stringOrNumber
is either of type string
or of type number
:
function getScore(stringOrNumber: string|number): number {
if (typeof stringOrNumber === 'string'
&& /^\*{1,5}$/.test(stringOrNumber)) {
return stringOrNumber.length;
} else if (typeof stringOrNumber === 'number'
&& stringOrNumber >= 1 && stringOrNumber <= 5) {
return stringOrNumber
} else {
throw new Error('Illegal value: ' + JSON.stringify(stringOrNumber));
}
}
assert.equal(getScore('*****'), 5);
assert.equal(getScore(3), 3);
stringOrNumber
has the type string|number
. The result of the type expression s|t
is the set-theoretic union of the types s
and t
(interpreted as sets).
undefined
and null
to typesIn TypeScript, the values undefined
and null
are not included in any type (other than the types undefined
, null
, any
and unknown
). That is common in statically type languages (with one notable exception being Java). We need union types such as undefined|string
and null|string
if we want to allow those values:
let numberOrNull: undefined|number = undefined;
numberOrNull = 123;
Otherwise, we get an error:
// @ts-expect-error: Type 'undefined' is not assignable to type 'number'.
let mustBeNumber: number = undefined;
mustBeNumber = 123;
Note that TypeScript does not force us to initialize immediately (as long as we don’t read from the variable before initializing it):
let myNumber: number; // OK
myNumber = 123;
Unions of string literals provide a quick way of defining a type with a limited set of values. For example, this is how the Node.js types define the buffer encoding that you can use (e.g.) with fs.readFileSync()
:
type BufferEncoding =
| 'ascii'
| 'utf8'
| 'utf-8'
| 'utf16le'
| 'utf-16le'
| 'ucs2'
| 'ucs-2'
| 'base64'
| 'base64url'
| 'latin1'
| 'binary'
| 'hex'
;
It’s neat that we get auto-completion for such unions (figure 4.1). We can also rename the elements of the union everywhere they are used – via the same refactoring that also changes function names.
Figure 4.1: The auto-completion for BufferEncoding
shows all elements of the union type.
Where a union type computes the union of two types, viewed as sets, an intersection type computes the intersection:
type Type1 = 'a' | 'b' | 'c';
type Type2 = 'b' | 'c' | 'd' | 'e';
type _ = Assert<Equal<
Type1 & Type2,
'b' | 'c'
>>;
The generic type
Assert<B>
is for comparing types
In this book, types are compared via the generic type Assert<B>
(more information).
One key use case for intersection types is combining object types (more information).
Sometimes we are faced with types that are overly general. Then we need to use conditions with so-called type guards to make them small enough so that we can use them. That process is called narrowing.
In the following code, we narrow the type of value
via the type guard typeof
:
function getLength(value: string | number): number {
assertType<string | number>(value); // (A)
// @ts-expect-error: Property 'length' does not exist on
// type 'string | number'.
value.length; // (B)
if (typeof value === 'string') {
assertType<string>(value); // (C)
return value.length; // (D)
}
assertType<number>(value); // (E)
return String(value).length;
}
It’s interesting to see how the type of value
changes, due to us using typeof
in the condition of an if
statement:
value
is string | number
(line A).
.length
in line B.
if
statement, the type of value
is string
(line C).
.length
(line D).
value
has type number
in line E.
Recall the two language levels of TypeScript:
Similarly:
Normal functions exist at the dynamic level, are factories for values and have parameters representing values. Parameters are declared between parentheses:
const valueFactory = (x: number) => x; // definition
const myValue = valueFactory(123); // use
Generic types exist at the static level, are factories for types and have parameters representing types. Parameters are declared between angle brackets:
type TypeFactory<X> = X; // definition
type MyType = TypeFactory<string>; // use
Naming type parameters
In TypeScript, it is common to use a single uppercase character (such as T
, I
, and O
) for a type parameter. However, any legal JavaScript identifier is allowed and longer names often make code easier to understand.
// Factory for types
type ValueContainer<Value> = {
value: Value;
};
// Creating one type
type StringContainer = ValueContainer<string>;
Value
is a type variable. One or more type variables can be introduced between angle brackets.
Classes can have type parameters, too:
class SimpleStack<Elem> {
#data: Array<Elem> = [];
push(x: Elem): void {
this.#data.push(x);
}
pop(): Elem {
const result = this.#data.pop();
if (result === undefined) {
throw new Error();
}
return result;
}
get length() {
return this.#data.length;
}
}
Class SimpleStack
has the type parameter Elem
. When we instantiate the class, we also provide a value for the type parameter:
const stringStack = new SimpleStack<string>();
stringStack.push('first');
stringStack.push('second');
assert.equal(stringStack.length, 2);
assert.equal(stringStack.pop(), 'second');
Maps are typed generically in TypeScript. For example:
const myMap: Map<boolean,string> = new Map([
[false, 'no'],
[true, 'yes'],
]);
Thanks to type inference (based on the argument of new Map()
), we can omit the type parameters:
const myMap = new Map([
[false, 'no'],
[true, 'yes'],
]);
assertType<Map<boolean, string>>(myMap);
Function definitions can introduce type variables like this:
function identity<Arg>(arg: Arg): Arg {
return arg;
}
We use the function as follows:
const num1 = identity<number>(123);
assertType<number>(num1);
Due to type inference, we can once again omit the type parameter:
const num2 = identity(123);
assertType<123>(num2);
The type of num2
is the number literal type 123
.
Arrow functions can also have type parameters:
const identity = <Arg>(arg: Arg): Arg => arg;
This is the type parameter syntax for methods:
const obj = {
identity<Arg>(arg: Arg): Arg {
return arg;
},
};
function fillArray<T>(len: number, elem: T): T[] {
return new Array<T>(len).fill(elem);
}
The type variable T
appears four times in this code:
fillArray<T>
. Therefore, its scope is the function.
elem
.
fillArray()
.
Array()
.
We can omit the type parameter when calling fillArray()
(line A) because TypeScript can infer T
from the parameter elem
:
const arr1 = fillArray<string>(3, '*');
assertType<string[]>(arr1);
assert.deepEqual(
arr1, ['*', '*', '*']);
const arr2 = fillArray(3, '*'); // (A)
assertType<string[]>(arr2);
Let’s use what we have learned to understand the piece of code we have seen earlier:
interface Array<T> {
concat(...items: Array<T[] | T>): T[];
reduce<U>(
callback: (state: U, element: T, index: number) => U,
firstState?: U
): U;
// ···
}
This is an interface for Arrays whose elements are of type T
:
method .concat()
:
T[]|T
. That is, it is either an Array of T
values or a single T
value. That means that the values in items
have the same type T
as the values in this
(the receiver of the method call).
T
.
method .reduce()
introduces its own type variable U
. U
is used to express the fact that the following entities all have the same type:
state
of callback()
callback()
firstState
of .reduce()
.reduce()
In addition to state
, callback()
has the following parameters:
element
: which has the same type T
as the Array elements
index
: a number
While using TypeScript, keep the following tip in mind.
strict
type checking whenever you canThere are many ways in which the TypeScript compiler can be configured. One important group of options controls how strictly the compiler checks TypeScript code. My recommendation is:
strict
should always be enabled.
You may be tempted to use settings that produce fewer compiler errors. However, without strict
checking, TypeScript simply doesn’t work as well and will detect far fewer problems in your code.
For more information on configuring TypeScript, see “Guide to tsconfig.json
” (§8).