Overview

TypeScript brings static types to JavaScript, allowing developers to build robust and reliable codebases.

benefits

The benefits of static types are not TypeScript-specific:

  • Enable static code analysis, which operates on code as-is (without the need to run the code)
  • Make the codebase more robust and reliable

TypeScript tooling ecosystem

  • The language service tool runs in the background at dev-time, and monitors open files continuously (part of the Language Server Protocol).
  • The type checker is invoked on-demand to type-check the entire project (tsc)
  • The compiler (also called transpiler) transpiles TS files to JS. (Transpiling doesn't require type-checking so it can be done by separate tools).

Basic types, Literal Types

primitive types

let x: number // includes NaN and Infinity
let x: string
let x: boolean
let x: null
let x: undefined
let x: bigint
let x: symbol

loose types (do not use)

The object and {} types are too broad to be useful:

  • object covers everything except primitive types, aka plain objects, arrays, functions, sets etc.
  • {} covers everything except null or undefined.
let x: object
let x: {}

literal types

The type consists of a single literal:

type T = "unavailable"

union of literals

We create a union of literal types. For example, a union type of string literals:

type T = "unavailable" | "available"

Object types

k items vs n items

in the following paragraphs:

  • items refer to the members of an object, aka the key-value pairs.
  • object with k items refers to a fixed amount of items: the number of items is controlled by TypeScript.
  • object with n items refers to any number of items: the number of items is not controlled by TypeScript.

arrays

n elements of the same type:

let a: T[]
let a: number[] // an array of number elements
// Array<number>

// 2D array
let a: number[][] // an array or array of number elements

note: technically, JavaScript supports arrays with elements of different type, but it's best not to use them.

tuples

an array of k elements, each with a specific type:

let t: [T, U]
let t: [x: T, y: U]
let t: [number, string]
let t: [hp: number, name: string] // labels purely for documentation

plain objects with k items (records)

a plain object with k items, aka a fixed list of properties, each with a name and a type.

interface PlayableCharacter {
    id: string
    level: number
}

type PlayableCharacter = {
    id: string
    level: number
}

plain objects with n items, with values of the same type (dictionaries)

n items of the same kind:

// Record<string, Word>
type WordDic = {
    [key: string]: Word
}

This fits dictionaries with any number of items.

plain objects with k items, with values of the same type (dictionaries)

The object has exactly k items, as the keys are constrained to a type union:

// Record<StructureName, Structure>
type PlanetStructures = {
    [key in StructureName]: Structure
}

This fits object that must provide an item for each value in a type union.

plain objects with k or less items, with values of the same type (dictionaries)

The object has k or less items, with keys conforming to a type union, with no exhaustivity:

type PlanetStructures = {
    [key in StructureName]?: Structure
}

the convention of using key

The term key in [key: string] or [key in ..] is a convention: we can use any other term, as it is for syntax only.

Structural typing for objects

conforming to an object type: provide the required capabilities

  • The object type is a contract specifying minimum capabilities.
  • Conforming objects guarantee to have those properties and methods, so that one can reliably work with them.
  • They can have unrelated, additional properties.

This behavior is called structural typing.

initialization with a literal: no unknown properties

When initializing with an object literal (fresh object literal), It is assumed that unknown properties at this stage are more likely to be errors or optional properties spelled with a typo. As such, TypeScript forbids the literal from having unknown properties. This is called "excess property check on fresh object literal".

In this example, it catches a misspelled property:

type BlogPost = {
    title: string
    subtitle?: string
}

const postA: BlogPost = {
    title: "Understanding TypeScript Types",
    subbtitle: "A practical introduction", // Object literal may only specify known properties, and 'subbtitle' does not exist in type 'BlogPost'.
}

assigning an existing object: unknown properties are ignored

The type-checker only checks if the existing object provides the properties and methods specified by the object type.

It ignores the other propertiesL

const draft = {
    title: "Understanding TypeScript Types",
    subtitle: "A practical introduction",
    author: "Antoine",
}

const postB: BlogPost = draft
postB.title // "Understanding TypeScript Types",
postB.author // Error: Property 'author' does not exist on type 'BlogPost'

Type narrowing

the case for type narrowing

Type narrowing is only needed when a type T is too broad, not precise enough for our needs. Instead, we want to work with values when they conform to U, a subset of T.

For example, when the value can be number or undefined, we want to filter out undefined values, so that we work with number values.

The main way to filter out unwanted values is through runtime checks combined with control flow, such as early returns or if statements. TypeScript can then assume the value is of type U, and so is the variable.

remove nullish values

We detect nullish and filter it out:

if (name == null) return
// nullish removed

if (name != null) {
    // nullish removed in this block
}

narrow down to a JS primitive

We identify the type with typeof:

if (typeof x !== "string") return
// guaranteed string

if (typeof x === "string") {
    // guaranteed string in this block
}

narrow to a Class instance

We use instanceof:

if (myDate instanceof Date) {
    // guaranteed Date
}

TypeScript escape hatches: unsafe operations

turning off type-checking partially or completely

For the following operations, we disable type-checking partially or entirely, either because we know something TypeScript doesn't know, or because we want to disable TypeScript temporarily

assert a value is non-nullish

We remove null and undefined from the value's type:

s // string | null | undefined
s! // string

assert a value conforms to a narrower type

We narrow down the type to a subset:

s // string | null | undefined
s as string // string

Note: in some scenarios, we type assert to a related type, not to a subset. The operation still requires the operation to be likely possible. If the operation is proven to be impossible because the types are incompatible, we must instead type assert to unknown first.

(do not use) assert a value conforms to an incompatible type

s // string | null | undefined
s as unknown as number // number

disable type-checking for a variable

let s: any
s = 3 // type-checker disabled
s = "foo" // type-checker disabled

Generic functions

define functions working with unresolved types

Generic functions work with unresolved types at the definition site:

function f<T, U>(x: T, y: U) {}

The actual types resolve at the call site, based on the type arguments or based on the values alone:

// T and U resolve to number:
f<number, number>(1, 2)
f(1, 2)

By default, a type T can be of any type and shape (unconstrained). The compiler cannot assume anything about it, similar to unknown or any. Variables of such unconstrained types are virtually unusable, since there are very few operations that work on unknown shapes.

Instead, it's better to constrain the type T (see constrain the unresolved type)

synopsis

  • First, we signal T is a placeholder type, by marking it with brackets
  • By default, it can be any type.
  • We constrain T to be a subset of a given type with extends. (optional)
  • Then, we use T as a regular type.
function identity<T>(x: T) {
    return x
}

the type becomes known at call site

The type becomes known:

  • explicitly through a type argument: the call can be declarative about the type
  • implicitly through the argument's value: the call provides the value and expects its type to be inferred
identity<number>(3)
identity(3)

constrain the unresolved type

We constrain the placeholder type T to conform to another type, possibly a type union:

function identity<T extends string | number>(x: T): T {
    return x
}

The actual type T is narrower than the type union, and is preserved in the function body.

It becomes known at the call site:

identity("Hi") // here, T is the 'Hi' string literal type, not string | number

Partial<T>

partial of a type: accept any subset

Make properties of an existing type optional, creating a new type. Conforming objects may contain any subset of the original properties:

interface SearchConfig {
    query: string
    inStock: boolean
    minPrice: number
    maxPrice: number
}

const searchConfig: Partial<SearchConfig> = {
    query: "RTX 5060",
    inStock: true,
}

example: typing merge update data

When updating an existing record with a merge strategy, we only provide the properties to be changed, that is, a subset of the original record:

type ProductUpdate = Partial<Product>

(advanced) Note: if the update should not affect some properties because they are immutable, we either:

  • white-list the mutable properties that can be included in the update object with Pick:
type ProductUpdate = Partial<Pick<Product, "price" | "description">>
  • exclude the immutable properties with Omit:
type ProductUpdate = Partial<Omit<Product, "id" | "slug">>

Derived types

keyof: derive a type union from the keys of an object type

We make a type union out of the keys of an object type (defined with interface or type)

keys of an object type can be strings, numbers and symbols. The output type is keyof Foo:

interface Foo {
    id: string
    name: string
    weight: number
}

type KeyOfFoo = keyof Foo
const s: KeyOfFoo = "id" // keyof Foo

type T = keyof Foo & string // "id" | "name" | "weight"

We can remove non-string keys from the type by adding & string, though this is not needed if the type is known to declare only string keys. When we do it, we end up with a string literal type union instead.

derive a type from a type map

In this example, we build a type map, mapping strings (the keys) to their type.

We make a function that works with a specific key of the map ( <K extends KeyOfFoo>), that identifies which key K is used, and that infers the associated type through the use of Foo[K].

In this example, the function makes use of the derived type as a type argument, to improve the precision of the function it calls:

/* 1.0 the type map */
interface DocTypes {
    bedtimeEvents: BedtimeEvent
    proteinEvents: ProteinEvent
    // ...
}

/* 2.0 keyof type union */
type CollectionName = keyof DocTypes

/* 3.0 a function that identifies the key and derives the type */
function f<K extends CollectionName>(colName: K) {
    return colRef<DocTypes[K]>(colName) // DocTypes[K] is used as a type argument
    // DocTypes[K] resolves to the actual type depending on the string
}

const colRef = f("bedtimeEvents") // CollectionReference<BedtimeEvent>
const colRef = f("proteinEvents") // CollectionReference<ProteinEvent>
earlymorning logo

Overview

TypeScript brings static types to JavaScript, allowing developers to build robust and reliable codebases.

benefits

The benefits of static types are not TypeScript-specific:

  • Enable static code analysis, which operates on code as-is (without the need to run the code)
  • Make the codebase more robust and reliable

TypeScript tooling ecosystem

  • The language service tool runs in the background at dev-time, and monitors open files continuously (part of the Language Server Protocol).
  • The type checker is invoked on-demand to type-check the entire project (tsc)
  • The compiler (also called transpiler) transpiles TS files to JS. (Transpiling doesn't require type-checking so it can be done by separate tools).

Basic types, Literal Types

primitive types

let x: number // includes NaN and Infinity
let x: string
let x: boolean
let x: null
let x: undefined
let x: bigint
let x: symbol

loose types (do not use)

The object and {} types are too broad to be useful:

  • object covers everything except primitive types, aka plain objects, arrays, functions, sets etc.
  • {} covers everything except null or undefined.
let x: object
let x: {}

literal types

The type consists of a single literal:

type T = "unavailable"

union of literals

We create a union of literal types. For example, a union type of string literals:

type T = "unavailable" | "available"

Object types

k items vs n items

in the following paragraphs:

  • items refer to the members of an object, aka the key-value pairs.
  • object with k items refers to a fixed amount of items: the number of items is controlled by TypeScript.
  • object with n items refers to any number of items: the number of items is not controlled by TypeScript.

arrays

n elements of the same type:

let a: T[]
let a: number[] // an array of number elements
// Array<number>

// 2D array
let a: number[][] // an array or array of number elements

note: technically, JavaScript supports arrays with elements of different type, but it's best not to use them.

tuples

an array of k elements, each with a specific type:

let t: [T, U]
let t: [x: T, y: U]
let t: [number, string]
let t: [hp: number, name: string] // labels purely for documentation

plain objects with k items (records)

a plain object with k items, aka a fixed list of properties, each with a name and a type.

interface PlayableCharacter {
    id: string
    level: number
}

type PlayableCharacter = {
    id: string
    level: number
}

plain objects with n items, with values of the same type (dictionaries)

n items of the same kind:

// Record<string, Word>
type WordDic = {
    [key: string]: Word
}

This fits dictionaries with any number of items.

plain objects with k items, with values of the same type (dictionaries)

The object has exactly k items, as the keys are constrained to a type union:

// Record<StructureName, Structure>
type PlanetStructures = {
    [key in StructureName]: Structure
}

This fits object that must provide an item for each value in a type union.

plain objects with k or less items, with values of the same type (dictionaries)

The object has k or less items, with keys conforming to a type union, with no exhaustivity:

type PlanetStructures = {
    [key in StructureName]?: Structure
}

the convention of using key

The term key in [key: string] or [key in ..] is a convention: we can use any other term, as it is for syntax only.

Structural typing for objects

conforming to an object type: provide the required capabilities

  • The object type is a contract specifying minimum capabilities.
  • Conforming objects guarantee to have those properties and methods, so that one can reliably work with them.
  • They can have unrelated, additional properties.

This behavior is called structural typing.

initialization with a literal: no unknown properties

When initializing with an object literal (fresh object literal), It is assumed that unknown properties at this stage are more likely to be errors or optional properties spelled with a typo. As such, TypeScript forbids the literal from having unknown properties. This is called "excess property check on fresh object literal".

In this example, it catches a misspelled property:

type BlogPost = {
    title: string
    subtitle?: string
}

const postA: BlogPost = {
    title: "Understanding TypeScript Types",
    subbtitle: "A practical introduction", // Object literal may only specify known properties, and 'subbtitle' does not exist in type 'BlogPost'.
}

assigning an existing object: unknown properties are ignored

The type-checker only checks if the existing object provides the properties and methods specified by the object type.

It ignores the other propertiesL

const draft = {
    title: "Understanding TypeScript Types",
    subtitle: "A practical introduction",
    author: "Antoine",
}

const postB: BlogPost = draft
postB.title // "Understanding TypeScript Types",
postB.author // Error: Property 'author' does not exist on type 'BlogPost'

Type narrowing

the case for type narrowing

Type narrowing is only needed when a type T is too broad, not precise enough for our needs. Instead, we want to work with values when they conform to U, a subset of T.

For example, when the value can be number or undefined, we want to filter out undefined values, so that we work with number values.

The main way to filter out unwanted values is through runtime checks combined with control flow, such as early returns or if statements. TypeScript can then assume the value is of type U, and so is the variable.

remove nullish values

We detect nullish and filter it out:

if (name == null) return
// nullish removed

if (name != null) {
    // nullish removed in this block
}

narrow down to a JS primitive

We identify the type with typeof:

if (typeof x !== "string") return
// guaranteed string

if (typeof x === "string") {
    // guaranteed string in this block
}

narrow to a Class instance

We use instanceof:

if (myDate instanceof Date) {
    // guaranteed Date
}

TypeScript escape hatches: unsafe operations

turning off type-checking partially or completely

For the following operations, we disable type-checking partially or entirely, either because we know something TypeScript doesn't know, or because we want to disable TypeScript temporarily

assert a value is non-nullish

We remove null and undefined from the value's type:

s // string | null | undefined
s! // string

assert a value conforms to a narrower type

We narrow down the type to a subset:

s // string | null | undefined
s as string // string

Note: in some scenarios, we type assert to a related type, not to a subset. The operation still requires the operation to be likely possible. If the operation is proven to be impossible because the types are incompatible, we must instead type assert to unknown first.

(do not use) assert a value conforms to an incompatible type

s // string | null | undefined
s as unknown as number // number

disable type-checking for a variable

let s: any
s = 3 // type-checker disabled
s = "foo" // type-checker disabled

Generic functions

define functions working with unresolved types

Generic functions work with unresolved types at the definition site:

function f<T, U>(x: T, y: U) {}

The actual types resolve at the call site, based on the type arguments or based on the values alone:

// T and U resolve to number:
f<number, number>(1, 2)
f(1, 2)

By default, a type T can be of any type and shape (unconstrained). The compiler cannot assume anything about it, similar to unknown or any. Variables of such unconstrained types are virtually unusable, since there are very few operations that work on unknown shapes.

Instead, it's better to constrain the type T (see constrain the unresolved type)

synopsis

  • First, we signal T is a placeholder type, by marking it with brackets
  • By default, it can be any type.
  • We constrain T to be a subset of a given type with extends. (optional)
  • Then, we use T as a regular type.
function identity<T>(x: T) {
    return x
}

the type becomes known at call site

The type becomes known:

  • explicitly through a type argument: the call can be declarative about the type
  • implicitly through the argument's value: the call provides the value and expects its type to be inferred
identity<number>(3)
identity(3)

constrain the unresolved type

We constrain the placeholder type T to conform to another type, possibly a type union:

function identity<T extends string | number>(x: T): T {
    return x
}

The actual type T is narrower than the type union, and is preserved in the function body.

It becomes known at the call site:

identity("Hi") // here, T is the 'Hi' string literal type, not string | number

Partial<T>

partial of a type: accept any subset

Make properties of an existing type optional, creating a new type. Conforming objects may contain any subset of the original properties:

interface SearchConfig {
    query: string
    inStock: boolean
    minPrice: number
    maxPrice: number
}

const searchConfig: Partial<SearchConfig> = {
    query: "RTX 5060",
    inStock: true,
}

example: typing merge update data

When updating an existing record with a merge strategy, we only provide the properties to be changed, that is, a subset of the original record:

type ProductUpdate = Partial<Product>

(advanced) Note: if the update should not affect some properties because they are immutable, we either:

  • white-list the mutable properties that can be included in the update object with Pick:
type ProductUpdate = Partial<Pick<Product, "price" | "description">>
  • exclude the immutable properties with Omit:
type ProductUpdate = Partial<Omit<Product, "id" | "slug">>

Derived types

keyof: derive a type union from the keys of an object type

We make a type union out of the keys of an object type (defined with interface or type)

keys of an object type can be strings, numbers and symbols. The output type is keyof Foo:

interface Foo {
    id: string
    name: string
    weight: number
}

type KeyOfFoo = keyof Foo
const s: KeyOfFoo = "id" // keyof Foo

type T = keyof Foo & string // "id" | "name" | "weight"

We can remove non-string keys from the type by adding & string, though this is not needed if the type is known to declare only string keys. When we do it, we end up with a string literal type union instead.

derive a type from a type map

In this example, we build a type map, mapping strings (the keys) to their type.

We make a function that works with a specific key of the map ( <K extends KeyOfFoo>), that identifies which key K is used, and that infers the associated type through the use of Foo[K].

In this example, the function makes use of the derived type as a type argument, to improve the precision of the function it calls:

/* 1.0 the type map */
interface DocTypes {
    bedtimeEvents: BedtimeEvent
    proteinEvents: ProteinEvent
    // ...
}

/* 2.0 keyof type union */
type CollectionName = keyof DocTypes

/* 3.0 a function that identifies the key and derives the type */
function f<K extends CollectionName>(colName: K) {
    return colRef<DocTypes[K]>(colName) // DocTypes[K] is used as a type argument
    // DocTypes[K] resolves to the actual type depending on the string
}

const colRef = f("bedtimeEvents") // CollectionReference<BedtimeEvent>
const colRef = f("proteinEvents") // CollectionReference<ProteinEvent>