HomepageExploring TypeScript (TS 5.8 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

4 The basics of TypeScript

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.

Icon “reading”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).

4.1 What you’ll learn

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.

4.2 How to play with code while reading this chapter

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).

4.3 What is a type?

In this chapter:

4.4 TypeScript’s two language levels

TypeScript is JavaScript plus syntax for adding static type information. Therefore, TypeScript has two language levels – two ways of using source code:

Program levelType level
Programming language isJavaScriptTypeScript
Source code isexecutedtype-checked
Types aredynamicstatic
Types exist atruntimecompile time

4.4.1 Dynamic types vs. static types

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;

Icon “details”@ts-expect-error shows type checking errors

In this book, type checking errors are shown via @ts-expect-error directives (more information).

4.4.2 JavaScript’s dynamic types

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:

  1. undefined: the set with the only element undefined
  2. null: the set with the only element null. Due to a historical bad decision, typeof returns 'object' for the value null and not 'null'.
  3. boolean: the set with the two elements false and true
  4. number: the set of all numbers
  5. bigint: the set of all arbitrary-precision integers
  6. string: the set of all strings
  7. symbol: the set of all symbols
  8. 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).

4.4.3 TypeScript’s static types

TypeScript brings an additional layer to JavaScript: static types. In source code, there are:

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.

4.4.3.1 A function with type annotations

The next example shows a function with type annotations:

function toString(num: number): string {
  return String(num);
}

There are two type annotations:

4.4.4 Revisiting the two language levels

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;

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.

4.5 Primitive literal types

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).

4.6 The types any, unknown and never

TypeScript has several types that are specific to the type level:

4.6.1 The wildcard type 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.

4.7 Type inference

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);

Icon “details”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));

4.7.1 The rules of type inference

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:

Step 3: By combining the results of step 1 and step 2, TypeScript can infer that strValue has the type string.

4.8 Type aliases

With type we can create a new name (an alias) for an existing type:

type Age = number;
const age: Age = 82;

4.9 Compound types

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.

4.10 Typing Arrays

TypeScript has two different ways of typing Arrays:

4.10.1 Array types: 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);

4.10.2 Tuple types: [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:

4.11 Function types

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.

4.11.1 Inferring function types

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);

4.11.2 Example: a function whose parameter is a function

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);

4.11.3 Inferring the return types of functions

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).

4.11.4 The special return type 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';
}

4.11.5 Optional parameters

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).

4.11.6 Parameter default values

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];
}

4.11.7 Rest parameters

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'
);

4.12 Typing objects

Similarly to Arrays, objects can be used in two ways in JavaScript (that are occasionally mixed):

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.

4.12.1 Typing fixed-layout objects via object literal types

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.

4.12.2 Interfaces as an alternative to object literal types

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.

4.12.3 TypeScript’s structural typing vs. nominal typing

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.

4.12.4 Optional properties

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',
};

4.12.5 Methods

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.

4.13 Union types

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).

4.13.1 Adding undefined and null to types

In 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;

4.13.2 Unions of string literal types

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.

4.14 Intersection types

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'
>>;

Icon “details”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).

4.15 Type guards and narrowing

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:

4.16 Type variables and generic types

Recall the two language levels of TypeScript:

Similarly:

Icon “tip”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.

4.16.1 Example: a container for values

// 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.

4.16.2 Example: a generic class

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');

4.16.3 Example: Maps

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);

4.16.4 Functions and methods with type parameters

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.

4.16.4.1 Arrow functions with type parameters

Arrow functions can also have type parameters:

const identity = <Arg>(arg: Arg): Arg => arg;
4.16.4.2 Methods with type parameters

This is the type parameter syntax for methods:

const obj = {
  identity<Arg>(arg: Arg): Arg {
    return arg;
  },
};
4.16.4.3 A more complicated function example
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:

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);

4.17 Conclusion: understanding the initial example

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:

4.18 Next steps

While using TypeScript, keep the following tip in mind.

4.18.1 Tip: Use strict type checking whenever you can

There 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:

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).