Tackling TypeScript
Please support this book: buy it or donate
(Ad, please don’t block.)

7 The essentials of TypeScript



This chapter explains the essentials of TypeScript.

7.1 What you’ll learn

After reading this chapter, you should be able to understand the following TypeScript code:

interface Array<T> {
  concat(...items: Array<T[] | T>): T[];
  reduce<U>(
    callback: (state: U, element: T, index: number, array: T[]) => 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.

7.2 Specifying the comprehensiveness of type checking

There are many ways in which the TypeScript compiler can be configured. One important group of options controls how thoroughly the compiler checks TypeScript code. The maximum setting is activated via --strict and I recommend to always use it. It makes programs slightly harder to write, but we also gain the full benefits of static type checking.

  That’s everything about --strict you need to know for now

Read on if you want to know more details.

Setting --strict to true, sets all of the following options to true:

We will see more compiler options later in this book, when we get to creating npm packages and web apps with TypeScript. The TypeScript handbook has comprehensive documentation on them.

7.3 Types in TypeScript

In this chapter, a type is simply a set of values. The JavaScript language (not TypeScript!) has only eight types:

  1. Undefined: the set with the only element undefined
  2. Null: the set with the only element 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)

All of these types are dynamic: we can use them at runtime.

TypeScript brings an additional layer to JavaScript: static types. These only exist when compiling or type-checking source code. Each storage location (variable, property, etc.) has a static type that predicts its dynamic values. Type checking ensures that these predictions come true.

And there is a lot that can be checked statically (without running the code). If, for example the parameter num of a function toString(num) has the static type number, then the function call toString('abc') is illegal, because the argument 'abc' has the wrong static type.

7.4 Type annotations

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

There are two type annotations in the previous function declaration:

Both number and string are type expressions that specify the types of storage locations.

7.5 Type inference

Often, TypeScript can infer a static type if there is no type annotation. For example, if we omit the return type of toString(), TypeScript infers that it is string:

// %inferred-type: (num: number) => string
function toString(num: number) {
  return String(num);
}

Type inference is not guesswork: It follows clear rules (similar to arithmetic) for deriving types where they haven’t been specified explicitly. In this case, the return statement applies a function String() that maps arbitrary values to strings, to a value num of type number and returns the result. That’s why the inferred return type is string.

If the type of a location is neither explicitly specified nor inferrable, TypeScript uses the type any for it. This is the type of all values and a wildcard, in that we can do everything if a value has that type.

With --strict, any is only allowed if we use it explicitly. In other words: Every location must have an explicit or inferred static type. In the following example, parameter num has neither and we get a compile-time error:

// @ts-expect-error: Parameter 'num' implicitly has an 'any' type. (7006)
function toString(num) {
  return String(num);
}

7.6 Specifying types via type expressions

The type expressions after the colons of type annotations range from simple to complex and are created as follows.

Basic types are valid type expressions:

There are many ways of combining basic types to produce new, compound types. For example, via type operators that combine types similarly to how the set operators union () and intersection () combine sets. We’ll see how to do that soon.

7.7 The two language levels: dynamic vs. static

TypeScript has two language levels:

We can see these two levels in the syntax:

const undef: undefined = undefined;

Note that the same syntax, undefined, means different things depending on whether it is used at the dynamic level or at the static level.

  Try to develop an awareness of the two language levels

That helps considerably with making sense of TypeScript.

7.8 Type aliases

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

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

7.9 Typing Arrays

Arrays play two roles in JavaScript (either one or both):

7.9.1 Arrays as lists

There are two ways to express the fact that the Array arr is used as a list whose elements are all numbers:

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 actually have to help it, because with an empty Array, it can’t determine the type of the elements.

We’ll get back to the angle brackets notation (Array<number>) later.

7.9.2 Arrays as tuples

If we store a two-dimensional point in an Array, then we are using that Array as a tuple. That looks as follows:

let point: [number, number] = [7, 5];

The type annotation is needed for Arrays-as-tuples because, for Array literals, TypeScript infers list types, not tuple types:

// %inferred-type: number[]
let point = [7, 5];

Another example for tuples is the result of Object.entries(obj): an Array with one [key, value] pair for each property of obj.

// %inferred-type: [string, number][]
const entries = Object.entries({ a: 1, b: 2 });

assert.deepEqual(
  entries,
  [[ 'a', 1 ], [ 'b', 2 ]]);

The inferred type is an Array of tuples.

7.10 Function types

This is an example of a function type:

(num: number) => string

This type comprises every function that accepts a single parameter of type number and return a string. Let’s use this type in a type annotation:

const toString: (num: number) => string = // (A)
  (num: number) => String(num); // (B)

Normally, we must specify parameter types for functions. But in this case, the type of num in line B can be inferred from the function type in line A and we can omit it:

const toString: (num: number) => string =
  (num) => String(num);

If we omit the type annotation for toString, TypeScript infers a type from the arrow function:

// %inferred-type: (num: number) => string
const toString = (num: number) => String(num);

This time, num must have a type annotation.

7.10.1 A more complicated example

The following example is more complicated:

function stringify123(callback: (num: number) => string) {
  return callback(123);
}

We are using a function type to describe the parameter callback of stringify123(). Due to this type annotation, TypeScript rejects the following function call.

// @ts-expect-error: Argument of type 'NumberConstructor' is not
// assignable to parameter of type '(num: number) => string'.
//   Type 'number' is not assignable to type 'string'.(2345)
stringify123(Number);

But it accepts this function call:

assert.equal(
  stringify123(String), '123');

7.10.2 Return types of function declarations

TypeScript can usually infer the return types of functions, but specifying them explicitly is allowed and occasionally useful (at the very least, it doesn’t do any harm).

For stringify123(), specifying a return type is optional and looks like this:

function stringify123(callback: (num: number) => string): string {
  return callback(123);
}
7.10.2.1 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 '"abc"' is not assignable to type 'void'. (2322)
  return 'abc';
}

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

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

7.10.4 Rest parameters

We can also use rest parameters in TypeScript parameter definitions. Their static types must be Arrays (lists or tuples):

function joinNumbers(...nums: number[]): string {
  return nums.join('-');
}
assert.equal(
  joinNumbers(1, 2, 3),
  '1-2-3');

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

7.11.1 By default, undefined and null are not included in types

In many programming languages, null is part of all object types. For example, whenever the type of a variable is String in Java, we can set it to null and Java won’t complain.

Conversely, in TypeScript, undefined and null are handled by separate, disjoint types. We need union types such as undefined|string and null|string, if we want to allow them:

let maybeNumber: null|number = null;
maybeNumber = 123;

Otherwise, we get an error:

// @ts-expect-error: Type 'null' is not assignable to type 'number'. (2322)
let maybeNumber: number = null;
maybeNumber = 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;

7.11.2 Making omissions explicit

Recall this function from earlier:

function stringify123(callback?: (num: number) => string) {
  if (callback === undefined) {
    callback = String;
  }
  return callback(123); // (A)
}

Let’s rewrite stringify123() so that parameter callback isn’t optional anymore: If a caller doesn’t want to provide a function, they must explicitly pass null. The result looks as follows.

function stringify123(
  callback: null | ((num: number) => string)) {
  const num = 123;
  if (callback === null) { // (A)
    callback = String;
  }
  return callback(num); // (B)
}

assert.equal(
  stringify123(null),
  '123');

// @ts-expect-error: Expected 1 arguments, but got 0. (2554)
assert.throws(() => stringify123());

Once again, we have to handle the case of callback not being a function (line A) before we can make the function call in line B. If we hadn’t done so, TypeScript would have reported an error in that line.

7.12 Optional vs. default value vs. undefined|T

The following three parameter declarations are quite similar:

If the parameter is optional, it can be omitted. In that case, it has the value undefined:

function f1(x?: number) { return x }

assert.equal(f1(123), 123); // OK
assert.equal(f1(undefined), undefined); // OK
assert.equal(f1(), undefined); // can omit

If the parameter has a default value, that value is used when the parameter is either omitted or set to undefined:

function f2(x = 456) { return x }

assert.equal(f2(123), 123); // OK
assert.equal(f2(undefined), 456); // OK
assert.equal(f2(), 456); // can omit

If the parameter has a union type, it can’t be omitted, but we can set it to undefined:

function f3(x: undefined | number) { return x }

assert.equal(f3(123), 123); // OK
assert.equal(f3(undefined), undefined); // OK

// @ts-expect-error: Expected 1 arguments, but got 0. (2554)
f3(); // can’t omit

7.13 Typing objects

Similarly to Arrays, objects play two roles in JavaScript (that are occasionally mixed):

We are ignoring objects-as-dictionaries in this chapter – they are covered in [content not included]. As an aside, Maps are usually a better choice for dictionaries, anyway.

7.13.1 Typing objects-as-records via interfaces

Interfaces describe objects-as-records. For example:

interface Point {
  x: number;
  y: number;
}

We can also separate members via commas:

interface Point {
  x: number,
  y: number,
}

7.13.2 TypeScript’s structural typing vs. nominal typing

One big advantage of TypeScript’s type system is that it works structurally, not nominally. That is, interface Point matches all objects that have the appropriate structure:

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

7.13.3 Object literal types

Object literal types are anonymous interfaces:

type Point = {
  x: number;
  y: number;
};

One benefit of object literal types is that they can be used inline:

function pointToString(pt: {x: number, y: number}) {
  return `(${pt.x}, ${pt.y})`;
}

7.13.4 Optional properties

If a property can be omitted, we put a question mark after its name:

interface Person {
  name: string;
  company?: string;
}

In the following example, both john and jane match the interface Person:

const john: Person = {
  name: 'John',
};
const jane: Person = {
  name: 'Jane',
  company: 'Massive Dynamic',
};

7.13.5 Methods

Interfaces can also contain methods:

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

interface HasMethodDef {
  simpleMethod(flag: boolean): void;
}
interface HasFuncProp {
  simpleMethod: (flag: boolean) => void;
}

const objWithMethod: HasMethodDef = {
  simpleMethod(flag: boolean): void {},
};
const objWithMethod2: HasFuncProp = objWithMethod;

const objWithOrdinaryFunction: HasMethodDef = {
  simpleMethod: function (flag: boolean): void {},
};
const objWithOrdinaryFunction2: HasFuncProp = objWithOrdinaryFunction;

const objWithArrowFunction: HasMethodDef = {
  simpleMethod: (flag: boolean): void => {},
};
const objWithArrowFunction2: HasFuncProp = objWithArrowFunction;

My recommendation is to use whichever syntax best expresses how a property should be set up.

7.14 Type variables and generic types

Recall the two language levels of TypeScript:

Similarly:

  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.

7.14.1 Example: a container for values

// Factory for types
interface 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.

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

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

// %inferred-type: Map<boolean, string>
const myMap = new Map([
  [false, 'no'],
  [true, 'yes'],
]);

7.15.2 Type variables for functions and methods

Function definitions can introduce type variables like this:

function identity<Arg>(arg: Arg): Arg {
  return arg;
}

We use the function as follows.

// %inferred-type: number
const num1 = identity<number>(123);

Due to type inference, we can once again omit the type parameter:

// %inferred-type: 123
const num2 = identity(123);

Note that TypeScript inferred the type 123, which is a set with one number and more specific than the type number.

7.15.2.1 Arrow functions and methods

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

7.15.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:

// %inferred-type: string[]
const arr1 = fillArray<string>(3, '*');
assert.deepEqual(
  arr1, ['*', '*', '*']);

// %inferred-type: string[]
const arr2 = fillArray(3, '*'); // (A)

7.16 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, array: T[]) => U,
    firstState?: U
  ): U;
  // ···
}

This is an interface for Arrays whose elements are of type T: