Skip to content

TypeScript Summary

Summary of Execute Program's TypeScript courses and other material.

Intro

TypeScript is a statically typed programming language built on JavaScript. Every TypeScript program is a valid JavaScript program, but with extra type annotations that are erased at compile time and don't exist at runtime.

Why TypeScript

It provides type safety by catching type errors at compile time, it's also useful to define contracts between functions and classes.

Limitations

  • It doesn't catch runtime errors. For user input, or data from external services, we need to use schema validation or alternatives like zod, io-ts, JSON schema, etc.
  • It's not type sound: static type guarantees are not always kept true at runtime.

Setup

Installation

  • Install TypeScript with a Node.js package manager (e.g. npm)
  • Compile js files with npx tsc file.ts
  • Start the REPL with tsx or ts-node
  • Use a linter typescript-eslint

Compiler options

Compiler options can be passed to tsc or moved to a tsconfig.json file.

  • --strict: enable all strict type checking options
  • --strictNullChecks
  • --noImplicitReturns: complain when a function declares a return type but some code paths don't return a value
  • --noFallThroughCasesInSwitch
  • --noUnusedLocals

The strict mode enables many strict options, listed below:

/* tsconfig.json. */
{
  "compilerOptions": {
    "strict": true,
  },
}

The noImplicitAny flag disallows function parameters with no type.

function add(x, y) {
  return x + y;
}

=> type error: Parameter 'x' implicitly has an 'any' type.

The strictNullChecks compiler flag doesn't allow us to assign null or undefined to variables that don't include those types.

const n: number = null;

=> type error: Type 'null' is not assignable to type 'number'.

The strictPropertyInitialization compiler flag forces us to assign class properties in the constructor. Otherwise, they could end up undefined, which would violate their type.

class User {
  name: string;
  age: number;

  constructor(name: string) {
    this.name = name;
  }
}
const alex: User = new User('alex');

=> type error: Property 'age' has no initializer and is not definitely assigned in the constructor.

REPL

Installed from ts-node, by https://github.com/TypeStrong/ts-node.

Import modules inside the REPL:

const util = await import('util')
const D = await import('io-ts/Decoder')

Inspect nested objects:

const util = await import('util')
util.inspect(test, { depth: null })

Data Types

Basic types: number, boolean, string

Tuples are arrays of fixed length, [number, number]

Literal type: use literal values as types. type sort = 'asc' | 'desc'

any: allows any value, effectively disables the type system for that expression, avoid!

const n: any = 5;

/* The static type of `s` is `string`, but it contains a `number`! */
const s: string = n;
s;

=> 5
unknown: a safe version of any. TypeScript won't let us use an unknown unless we narrow it to a more specific type.
const n: unknown = 5;
const n2: number = n;

=> type error: Type 'unknown' is not assignable to type 'number'.
object: allows objects with any properties, but we can't access those properties. Found mostly in legacy code.
const user: object = {
  name: 'Alex',
};
user.name;

=> type error: Property 'name' does not exist on type 'object'.
void: Functions without return values have void as their return type.
function f() { }
const n: number = f();

=> type error: Type 'void' is not assignable to type 'number'.
never: used for values that will never actually occur at runtime.
function throws(): never {
  throw new Error('oh no');
}
throws();

=> Error: oh no
TypeScript can statically type JavaScript objects like: Set, Map, Date, Regexp, Error, Array, Promise.
const s: Set<number> = new Set([1, 2, 3]);
s.add('this is not a number');

=> type error: Argument of type 'string' is not assignable to parameter of type 'number'.

Promises or Async/Await

const promise: Promise<number> = Promise.resolve(100);
promise;

=> {fulfilled: 100}
async function asyncDouble(
  x: Promise<number>
): Promise<number> {
  return 2 * await x;
}

asyncDouble(Promise.resolve(5));

=> {fulfilled: 10}
Exceptions / Rejected promises: TypeScript can't use concrete types for caught exceptions. We type them with any or unknown (recommended).
let error: Error;

try {
  throw new Error('something broke');
} catch (e: unknown) {
  if (e instanceof Error) {
    error = e;
  } else {
    throw new Error("We can't catch that!");
  }
}
error.message;

=> 'something broke'
Promise.reject('it failed')
  .catch((reason: unknown) => {
    if (typeof reason === 'string') {
      return reason 
    } else {
      return reason.toString()
    }
  });

=> {fulfilled: 'it failed'}

Classes

TypeScript supports static typing of JavaScript classes.

class Cat {
  name: string;
  #vaccinated: boolean;

  constructor(
    name: string,
    vaccinated: boolean
  ) {
    this.name = name;
    this.#vaccinated = vaccinated;
  }

  needsVaccination(): boolean {
    return !this.#vaccinated;
  }
}
Classes can implement one or more interface. TypeScript will ensure that the class has all of the properties declared in the implemented interfaces.
type HasName = {
  name: string
};

class User implements HasName {
  name: string;

  constructor(name: string) {
    this.name = name;
  }
}

const alex = new User('Alex');
const hasName: HasName = alex;
hasName.name;

=> 'Alex'
JavaScript and TypeScript don't support multiple inheritance. However, classes can implement multiple interfaces, which serves some of the use cases of multiple inheritance.

Functions

Declare function explicitly:

// traditional functions
function myFunction(x: string): string {
    return x;
}
// arrow functions
const myFunction2: (x: string) => string = (x) => {
    return x;
}
Functions can take other functions. We use the standard function type syntax to declare them.
function callFunction(f: () => number) {
  return f();
}

callFunction(() => 1);

=> 1

Function parameters can be optional.

function add(x: number, y?: number) {
  return x + (y ?? 1);
}
[add(3, 4), add(3)];

=> [7, 4]

Parameters can have default values.

function add(x: number, y: number = 1) {
  return x + y;
}
[add(10, 20), add(30)];

=> [30, 31]

Functions can have "rest parameters", which gather an arbitrary number of arguments into a single local variable.

function add(...numbers: number[]) {
  let sum = 0;
  for (const n of numbers) {
    sum += n;
  }
  return sum;
}
[add(), add(1, 2), add(100, 200, 300)];

=> [0, 3, 600]

Hybrid types let us define function types that also have properties like an object does.

/* This is a function type, but it also has a
 * 'prefix' property. */
interface Path {
  (suffix: string): string
  prefix: string
}

Function overloads let us write multiple type signatures for a single function. The main function body must have a type that's simultaneously compatible with all of the signatures.

When TypeScript type checks the function body, it only checks it against the main type, not the individual overload types. Mistakes in the function body can allow type errors to pass silently.

/* This function returns a number when given a
 * number, and returns a string when given a
 * string. */
function double(n: number): number;
function double(s: string): string;
function double(
  n: number | string
): number | string {
  /* implementation goes here */
}

When considering function parameters, type compatibility is "backwards".

function takesLiteral(s: 'age'): string {
  return s;
}

type TakesString = (s: string) => string;

/* This is a type error. We can't assign a
 * function that takes the literal string 'age'
 * to a function type that takes a `string`. */
const testFunction: TakesString = takesLiteral;

When considering function return types, type compatibility works normally.

function returnsString(): string {
  return 'age';
}

type ReturnsLiteral = () => 'age';

/* This is a type error due to the function
 * return type compatibility rules. If
 * TypeScript allowed this, `testFunction`'s
 * type would say "I return 'age'", but the
 * underlying function's type can return any
 * string. */
const testFunc: ReturnsLiteral = returnsString;

We can use function overloads to statically type complex functions with option parameters.

/* This function returns different static types
 * depending on the options we pass in. We
 * define two signatures, then the body. */
function findUser(id: number): User;

function findUser(
  id: number,
  options: {withComments: true}
): UserWithComments;

function findUser(
  id: number,
  options: {withComments?: boolean} = {}
): User | UserWithComments {
  /* implementation goes here */
}

Objects

TypeScript's structural type system allows extra properties on objects. At runtime, our objects may have properties that aren't specified in their static types.

const user = {
  name: 'Alex',
  email: 'alex@example.com'
};
const hasEmail: {email: string} = user;

/* `hasEmail`'s static type doesn't have a
 * `name` property, but the property is still
 * present at runtime. */
(hasEmail as any).name;

=> 'Alex'

The empty object type {} allows any object. It specifies no properties, so we can't access any properties on the object.

function takesAnObject(obj: {}) {
  return 'it worked';
}

[
  takesAnObject({name: 'Alex'}),
  takesAnObject({loginCount: 55}),
];

=> ['it worked', 'it worked']

Object types can contain method definitions in addition to regular properties.

type Dog = {
  name: string
  bark(): string
};

const dog: Dog = {
  name: 'Luna',
  bark: () => 'woof',
};

dog.bark();

=> 'woof'

The object spread operator works in TypeScript, and it enforces types as usual.

const partialAlex = {name: 'Alex'};
const fullAlex = {...partialAlex, admin: true};
fullAlex;

=> {name: 'Alex', admin: true}

If we specify unknown properties when defining an object literal, TypeScript gives us a type error. This stops us from specifying useless properties, and it can even prevent bugs in some cases.

const user: {name: string} = {
   name: 'Alex',
   age: 36
};

=> type error: Type '{ name: string; age: number; }' is not assignable to type '{ name: string; }'.
  Object literal may only specify known properties, and 'age' does not exist in type '{ name: string; }'.

We can statically type complex functions with option types by combining default parameter values and optional object properties.

/* Callers can specify none, some, or all of
 * `opts`. We'll use default values for any
 * options that are missing. */
function sendEmail(
  to: string,
  opts: {
    tracking?: boolean
    retries?: number
  } = {}
) {
  const tracking = opts.tracking ?? true;
  const retries = opts.retries ?? 3;
  /* Send email here. */
}

Arrays

Arrays are technically objects, so typeof returns 'object'. This behavior originally comes from JavaScript.

typeof [1, 2, 3];

=> 'object'
Conditional narrowing on arrays is complicated by the fact that typeof someArray returns 'object'. To narrow an array, we have to use Array.isArray instead.
function maybeLength(maybeArray: unknown) {
  if (Array.isArray(maybeArray)) {
    return maybeArray.length;
  } else {
    return undefined;
  }
}

Operators and Keywords

type and interface define custom types.

Type algebra: TypeScript types follow algebraic laws, including the distributive law.

// These two types are equivalent.
type T1 = IsAdmin & (User | Cat);
type T2 = (IsAdmin & User) | (IsAdmin & Cat);
is is a type predicate operator for custom type guards.
function isFoo(input: any): input is Foo {
    return input.foo !== undefined
}
if (isFoo(x)) ... // type guard
The as operator forces a value to have a certain static type. It's very unsafe, like any, because it lets us specify types that are incorrect.
const obj = {};
/* This is supposed to be a `string`, but it's
 * an object. */
/* The static type here is `string`, but our
 * variable actually holds `object`. */
const wronglyTypedObj: string = obj as string;
The keyof operator returns an object type's keys as a union of literal string types.
type User = {
  name: string
  age: number
};

// This is a 'name' | 'age'.
type UserKey = keyof User;
The typeof operator returns the static type of a variable, function, class, etc.
const one: number = 1;
// This type is `number`.
const two: typeof one = 2;
The infer keyword can be used inside of conditional types to extract a type parameter from a generic type.
/* This type extracts the type inside of an
 * array type. This would be impossible without
 * `infer`. */
type ArrayContents<T> =
  T extends Array<infer ElementType>
  ? ElementType
  : never;

// This type is `number`.
type MyNumber = ArrayContents<Array<number>>;
The ?. operator is like the regular . operator for property access. However, if the value to the left of the ?. is null or undefined, then the overall expression returns undefined.
function getUser(): User | undefined {
  return {name: 'Alex'};
}

/* Return the user's name, or undefined if we
 * got undefined back. */
getUser()?.name;
The nullish coalescing operator ?? is similar to ||, but more safe. It returns its right side when its left side is either undefined or null. It's most often used to specify default values for when we encounter an undefined or null.
> null ?? 1;
=> 1

> undefined ?? 'fallback';
=> 'fallback'

> false ?? [2];
=> false

The satisfies operator lets us validate that the type of an expression matches some type, without changing the resulting type of that expression.

interface Status {
  status: "failure" | "success";
}

const info: Status = {
  status: "failure",
};

info.status; // this type is: "failure" | "success"

const info2 = {
  status: "failure",
} satisfies Status;

info2.status; // this type is "failure"

as const creates immutable types and narrows the type of a variable to its literal value, making object properties readonly and turning arrays into readonly tuples.

// this type is: readonly ['admin', 'user']
const roles = ['admin', 'user'] as const;

// this type is "admin" | "user"
type Role = typeof roles[number]; 

Template literal types allow us to create dynamic types using string literals.

type UserId = `user_${number}`;

const user: UserId = 'user_123';

const notUser: UserId = 'not_user_123';
=> Type '"not_user_123"' is not assignable to type '`user_${number}`'.

Defining and constructing types

Type and Interface

Declare object types with interface or type.

// User1 and User2 are equivalent
interface User1 {
  name: string
}

type User2 = {
  name: string
};
interface can only describe object shapes, and can be extended.

Intersection

A & B means "any type that's compatible with both A and B.

type HasEmail = {email: string};
type CanBeAdmin = {admin: boolean};

// This type is {email: string, admin: boolean}.
type User = HasEmail & CanBeAdmin;

We can use intersections to share fields between object types.

type HasEmail = {email: string};

type User = HasEmail & {
  admin: boolean
};

type Company = HasEmail & {
  accountNumber: number
};
Some type intersections can't actually happen at runtime. Those intersections give us the type never.
type HasEmail1 = {
  email: string
};
type HasEmail2 = {
  email: number
};

/* The `email` property can't be both a string and a number. */
type User = HasEmail1 & HasEmail2;
const user: User = {
  email: 'alex@example.com'
};

=> type error: Type 'string' is not assignable to type 'never'.

Union

A | B means "any type that's compatible with either A or B.

type accountId = string | number;

Discriminated unions are unions of multiple object types. They share a property called the discriminator, where each object type specifies a different literal value for the discriminator. At runtime, we can use the discriminator property to decide which union alternative we're looking at. Discriminators can be any literal type.

type StartedCourse = {
  started: true // discriminator
  lastInteractionTime: Date
};
type UnstartedCourse = {
  started: false // discriminator
};
type Course = StartedCourse | UnstartedCourse;

Discriminated unions are also useful for handling errors. The "success" side of the union contains the result value, and the "failure" side contains details about what went wrong.

type ConversionSucceeded = {
  kind: 'success'
  value: number
};

type ConversionFailed = {
  kind: 'failure'
  reason: string
};

type Conversion =
  ConversionSucceeded | ConversionFailed;

function safeNumber(s: string): Conversion {
  /* convert to number here */
}

const maybeNumber = safeNumber(s)
if (maybeNumber.kind === 'success') {
  // access value
} else {
  // access reason
}
A union with never has no effect. T | never is always just T.
function returnNumberOrNever(): number | never {
  return 1;
}

// The return type of the function is `number`.
const n: number = returnNumberOrNever();

When indexing into an object type with a union, the indexing operator "distributes over" the union.

type User = {
  name: string
  age: number
};

// This is a `string | number`.
type PropertyTypes = User['name' | 'age'];

Optional properties

Object properties can be optional by adding the ? syntax. We don't have to specify optional properties when building an object at runtime.

type User = {
  name: string
  postalCode?: string
};

const alex: User = {
  name: 'Alex',
};

alex.postalCode;

=> undefined

Extending types

Interfaces can extend other interfaces as well as classes.

interface CanBeAdmin {
  admin: boolean
}

interface User extends CanBeAdmin {
  name: string
}

const user: User = {name: 'Alex', admin: true};
user;

=> {name: 'Alex', admin: true}

Index Signatures

Index signatures allow us to access any property name on an object. We don't have to declare the properties one-by-one. All of the properties must have the same static type.

type LoginCounts = {
  // This is the index signature.
  [userName: string]: number
};
const loginCounts: LoginCounts = {
  Alex: 5,
  Bernd: 7,
};

TypeScript can't know whether any given property name will actually exist or not. If we access a property that doesn't exist, we'll get undefined, which violates our declared static type.

type UserEmails = {[name: string]: string};
const userEmails: UserEmails = {
  Alex: 'alex@example.com'
};

/* The type of this expression is `string`, but
 * we actually get `undefined` at runtime. */
userEmails.Bernd;
=> undefined

Indexing into object and array types

We can index into object types directly, giving us the types of individual properties.

type Album = {
  name: string
  copiesSold: number
};

// This type is `string`.
type AlbumName = Album['name'];

Indexing into an array type gives us the array's element type, no matter which index we ask for.

type Names = string[];

// This type is `string`.
const username: Names[0] = 'alex';

type StringAndNumber = [string, number];

// This type is `number`.
const age: StringAndNumber[1] = 29;

Recursive types

Types can refer to themselves.

> type Nested = number | Nested[];
const n: Nested = [[[[1]], 2, [3]]];

Mapped Types

Mapped types turn one object type into another. They "map over" the property types, replacing them with new types.

type User = {
  email: string
};

// This type is {email: Array<string>}.
type UserAggregate = {
  // This is the mapped type.
  [K in keyof User]: Array<User[K]>
};
TypeScript's built-in Partial type is implemented as a mapped type.
type OurPartial<T> = {
    [P in keyof T]?: T[P];
};
Mapped types work with index signatures.
type Nullable<T> = {
  // This is the mapped type.
  [K in keyof T]: T[K] | null
};

type LoginCounts = {
  // This is the index signature.
  [user: string]: number
};

// This is {[user: string]: number | null}.
type NullableLoginCount = Nullable<LoginCounts>;

keyof with typeof

keyof and typeof are often used together. We can use them to get a type for the object keys of an existing variable.

const icons = {
  rightArrow: 'right arrow image',
  billing: 'billing image',
};

// This is 'rightArrow' | 'billing'.
type IconName = keyof typeof icons;

Conditional Types

Conditional types have one of two types, depending on whether a certain condition is true. TypeScript only supports conditions that use extends.

type WrapStringInArray<T> =
  T extends string ? Array<string> : T;

// The type here is `Array<string>`.
const s: WrapStringInArray<string> = ['hello'];
Conditional types distribute over union types. The conditional type is applied to each union alternative separately.
type Exclude<Original, ToExclude> =
  Original extends ToExclude ? never : Original;

// This type is `C | D`.
type Excluded = Exclude<A|B|C|D, A|B>;

Type Predicates

Type predicates in TypeScript are functions that help narrow down types by asserting that a variable is of a specific type if the function returns true.

Type predicates allow us to write our own type guard functions. They have someArgument is SomeType in place of a regular return value.

> const maybeAddress: Address | undefined = getAddress();

function isAddress(
  address: Address | undefined
): address is Address {
  return address !== undefined;
}

if (isAddress(maybeAddress)) {
  /* In here, the type of `address` is
   * `Address`. */
}

/* Down here, the type of `address` is
 *`Address | undefined` again. */

The array filter function often benefits from type predicates.

Normally, filter returns an array of the same type, even if we pass a function that seems like it should narrow the type. If we make our function a type predicate, then our filter call can return a narrowed type rather than the original array's type.

function isNumber(
  maybeNumber: number | string
): maybeNumber is number {
  return typeof maybeNumber === 'number';
}

const numbersAndStrings: (number | string)[] =
  [1, 'a', 2, 'b'];

/* This assignment works because `filter`
 * respects our `isNumber` type predicate. This
 * `filter` call narrows the array from
 * (number | string)[] to just number[]. */
const numbers: number[] =
  numbersAndStrings.filter(isNumber);

If we make a mistake and implement our type predicate function incorrectly, TypeScript won't know. It will blindly trust our predicate, which can lead to incorrect types.

> function isNumber(
  n: number | string
): n is number {
  return typeof n === 'string';
}
const n: string = 'oh no';
/* This is a bug! Our `isNumber` function is
 * wrong, so it lets us put a string in a
 * number variable. */
const n2: number = isNumber(n) ? n : 0;
n2;

=> 'oh no'

Type predicate arguments are a great place to use unknown. Then they can work with any argument.

function isString(
  s: unknown
): s is string {
  return typeof s === 'string';
}
const s: unknown = {isCat: true};
const s2: string = isString(s)
  ? s
  : 'not a string';
s2;

=> 'not a string'

Assertion functions throw an error unless some condition is true. When we call one, TypeScript knows that the asserted condition must be true after that point.

function assert(cond: boolean): asserts cond {
  if (!cond) {
    throw new Error('Failed');
  }
}

assert(typeof numberOrString === 'number');

/* `numberOrString` was a `number | string`.
 * After our assertion, it's narrowed to just
 * `number`. */

Enum

Enums allow us to express types that only accept certain literal values. Discouraged.

enum HttpMethod {
  Get = 'GET',
  Post = 'POST',
}
const method: HttpMethod = HttpMethod.Post;
method;

=> 'POST'

Generics

Generics are data types that take another type as an argument, e.g. Array<string>.

Generic object types, interfaces, and classes all have generic type parameters immediately after the type's name.

type Bag<T> = {
  value: T
};
Regular functions, arrow functions, and hybrid types all have generic type parameters immediately before the argument list.
const returnsItsArgument = <T>(arg: T): T => {
  return arg;
};
Default Parameters: Generic type parameters can have default types. If we use the generic type without specifying its type parameter, we'll get the default.
> type OurArray<T=string> = Array<T>;

// This is an Array<string>.
const names: OurArray = ['Alex', 'Bernd'];

Generic Constraints: Constraints force a generic type parameter to extend some other type. This lets us introduce limitations like "this function is generic, but the parameter must be an object with an age parameter".

/* `filterByAge` works with arrays of any
 * object type, as long as it has an
 * `age: number` property. */
function filterByAge<T extends {age: number}>(
  things: Array<T>,
  max: number
): Array<T> {
  return things.filter(
    thing => thing.age < max
  );
}
Sometimes a generic constraint needs a type parameter, but we don't care what that parameter is. We can use unknown in those cases.
type ItemsObject<A extends Array<unknown>> = {
  items: A
};

const someNumbers: ItemsObject<number[]> = {
  items: [1, 2, 3]
};

Constraints and Type Compatibility: When we have a type constraint like <T extends string>, it doesn't mean "T is a string". It means "T is some type that's compatible with string". That type could be 'name' or 'name' | 'age' or just string.

type SpecificKey<T extends string> = {key: T};

// This type is {key: 'name' | 'age'}.
type NameOrAgeObj = SpecificKey<'name' | 'age'>;
keyof Generic Constraints: We can use keyof in generic constraints, like Key extends keyof T. That means "Key is any type that's compatible with one or more of T's keys".
function get<T, Key extends keyof T>(
  obj: T,
  key: Key
): T[Key] {
  return obj[key];
}

/* The `get` function uses a keyof constraint to
 * return the correct type for the requested
 * property key. This call returns a string, but
 * it would return a number if we asked for
 * 'age' instead of 'name'. */
const name: string = get(
  {name: 'Alex', age: 36},
  'name'
);

Utility Types

Record

Record is used to construct object types. It's useful when mapping properties of one type to another.

interface UserInfo {
    email: string;
    age: number;
}

// Here we map type `string` to type `UserInfo`
const users: Record<string, UserInfo> = {
  alex: { age: 30, email: "alex@example.com" },
  bard: { age: 40, email: "bard@example.com" }
};

Pick & Omit

With the Pick type we choose which properties to include.

type User = {
  name: string
  email: string
  age: number
};

// This type is {name: string}.
type NameOnly = Pick<User, 'name'>;
The Omit type lets us create a new object type with some properties removed.
type User = {
  name: string
  email: string
  age: number
};

// This type is {name: string}.
type NameOnly = Omit<User, 'email' | 'age'>;

Partial & Required

The Partial generic type makes every property in an object type optional.

type User = {
  name: string
  postalCode: string
};

const partialAlex: Partial<User> = {
  postalCode: '7010'
};
partialAlex.postalCode;

=> '7010'

We can use the Partial type to create object template systems.

type User = {
  name: string
  country: string
};

const template: User = {
  name: 'Alex',
  country: 'UK',
};

function override(
  overrides: Partial<User>
): User {
  return {...template, ...overrides};
}

override({name: 'Bernd'});

=> {name: 'Bernd', country: 'UK'}
The Required generic type makes every optional property in an object type required.

Readonly

The readonly modifier makes an individual object property read-only.

interface User {
  readonly name: string
}

const alex: User = {name: 'Alex'};
alex.name = 'Bernd';

=> type error: Cannot assign to 'name' because it is a read-only property.
The Readonly generic type makes all properties on an object type read-only.
type User = Readonly<{
  name: string
  age: number
}>;

const alex: User = {name: 'Alex', age: 36};
alex.name = 'Bernd';

=> type error: Cannot assign to 'name' because it is a read-only property.

Readonly Properties vs. Values:

type User = {
  name: string
  readonly catNames: string[]
};

const alex: User = {
  name: 'Alex',
  catNames: ['Ms. Fluff'],
};

alex.catNames.push('X') // no error

alex.catNames = 'X'
=> type error: Cannot assign to 'catNames' because it is a read-only property.

Read-only arrays act like normal arrays, except that we can't modify their elements. We also can't call any methods that would change the array, like .push(...) or .sort().

const strings: ReadonlyArray<string> = [
  'a',
  'b',
  'c'
];
strings.push('d');

=> type error: Property 'push' does not exist on type 'readonly string[]'.

ReturnType, Parameters & Awaited

The ReturnType generic type gives us a function's return type.

type StringFunction = () => string;

// This type is `string`.
type OurString = ReturnType<StringFunction>;
The Parameters generic type gives us a function's parameters as a tuple type.
type DoubleFunction = (n: number) => number;

// This type is `number`.
type OurNumber = Parameters<DoubleFunction>[0];
The Awaited generic type recursively unwrap a Promise to reveal the type.
// this type is `string`
type A = Awaited<Promise<string>>;

Other Utility Types

Other useful utility types can be found on the TypeScript official site

Namespaces

Namespaces are like modules, except that more than one namespace can live in a single file. TypeScript supports namespaces, but they're generally discouraged in new code. We recommend using separate modules in their own files instead.

namespace Util {
  export function wordCount(s: string) {
    return s.split(/\b\w+\b/g).length - 1;
  }
}

namespace Tests {
  export function testWordCount() {
    if (Util.wordCount('hello there') !== 2) {
      throw new Error('Word count mismatch');
    }
    return 'ok';
  }
}

Tests.testWordCount();

=> 'ok'

Errors

  • Syntax error are caught by the JavaScript runtime
  • Type errors caught by the TypeScript compiler:
    '2' * '2'
    
    => error: The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
    
    { a: 'a' }.b()
    
    => error: Property 'b' does not exist on type '{ a: string; }'
    
  • Undefined in arrays (not an error by default!)
    const strings: string[] = ['a', 'b']
    const element: string = strings[10] // should be typed as undefined, but is typed as string
    
    avoid by enabling two compiler options: --strictNullChecks and --noUncheckedIndexedAccess:
    const strings: string[] = ['a', 'b']
    const element: string = strings[10] // should be typed as undefined, but is typed as string
    
    => Type 'string | undefined' is not assignable to type 'string'.
    
  • Impossible conditions: If condition in an if is impossible, the compiler will complain.
    let s;
    if (1 === 0) {
      s = 'it worked';
    }
    
    => type error: This comparison appears to be unintentional because the types '1' and '0' have no overlap.
    
  • No exhaustiveness checking: To make the compiler check a switch statement exhaustively, declare an explicit return type for the function, then write a switch where each case returns.
    type User = {kind: 'user', userName: string};
    type Cat = {kind: 'cat', catName: string};
    type Dog = {kind: 'dog', dogName: string};
    
    function getName(
      hasName: User | Cat | Dog,
    ): string {
      switch (hasName.kind) {
        case 'user':
          return hasName.userName;
        case 'cat':
          return hasName.catName;
      }
    }
    
    => type error: Function lacks ending return statement and return type does not include 'undefined'.
    

Terms

Type inference

the compiler infers the data type of an expression or a function return type without explicitly declaring it

Type erasure

types are only used for type checking, then erased from the compiled output

Generics

Data type that takes another type as an argument, e.g. Array<string>

Literal type

use literal values as types. type sort = 'asc' | 'desc'

Literal object type

Declare the object type directly in the function signature (instead of naming it using type) function userName(user: { name: string }) { return user.name; }

Object narrowing / type compatibility

Narrow the larger type to a smaller type. TypeScript will automatically do this.

type User = { name: string, email: string } // larger type
type HasEmail = {email: string }            // smaller type

function sendEmail(args: HasEmail) { .. }

// both work because TypeScript does object narrowing
sendEmail(user: User)
sendEmail(email: HasEmail)
Conditional narrowing: Using a conditional statement & type guards to narrow the type of the variable.
const userOrUsers: User | User[] = { name: 'test' }

function nameOrLength(arg: User | User[]) {
    if (Array.isArray(arg)) {
        return arg.length
    } else {
        return arg.name
    }
}
Logical operators like && narrow types. The right side of the && is only evaluated if the left side is true, so it acts like a conditional.
function arrayLength(
  strings: string[] | undefined
): number | undefined {
  return strings && strings.length;
}
Ternary conditionals also narrow types.
function lengthOrNumber(
  n: number | number[]
): number {
  return Array.isArray(n) ? n.length : n;
}

Type guard

Function or expression when used in a conditional statement will guarantee the type of the variable. Array.isArray() or the is keyword Guarantees that code executes only for certain types

Structural typing

The object's structure determines the type. AKA duck typing.

Nominal typing

Type compatibility depends on explicit declarations. User is a bigger kind of HasEmail

Type soundness

In a sound type system, all static type guarantees remain true at runtime. TypeScript is unsound: in some situations, the static types will be violated at runtime.

let names: string[] = ['Alex', 'Bernd'];
let unsoundNames: (string | number)[] = names;
/* This is unsound! It causes the `names` array
 * to contain a number, but `names` is supposed
 * to be a `string[]`! */
unsoundNames.push(5);
names;

=> ['Alex', 'Bernd', 5]

Type widening

TypeScript widens the types of literal values where possible.

// This type is {name: string}.
let user = {name: 'Alex'};
Sometimes we don't want TypeScript to widen the types in this way. We can add as const to prevent widening of a specific expression.
// This type is {name: 'Alex'}.
let user = {name: 'Alex' as const};