Types in TypeScript

Basic and built-in Types

Boolean, Number, String, Array, Enum, Void, Null, Undefined, Never, Any.

Union Types

A Union Type is created by joining multiple types via the pipe symbol |, for example A | B.

A Union of Type A | B means…

  • the object (having this union type) must either be A or B
  • in other words: the object must have at least all members of A or all members of B
  • any other members that neither exist in A nor B will cause an error
type A = {
    age: number;
    name: string
}

type B = {
    skill: string;
    name: string;
}

const union: A | B = {
    age: 40,
    name: "test",
    skill: "",
}

// The following will cause an error.
// Because 'name' is missing, the type is neither A nor B.
const errorUnion: A | B = {
    age: 40,
    skill: "",
}

So if you have an object of type A | B you cannot really be sure whether you have an object that contains all members of A or all members of B. But you can be sure that the object has members that both A and B have. To get to the actual type of an object, you must narrow down the type.

In the next example we have a union type number | string. Calling toString() on a type string | number works, because string and number both have implementations of toString(), but calling toUpperCase() gives an error:

function printId(id: number | string) {
  console.log(id.toUpperCase());
}
Property 'toUpperCase' does not exist on type 'string | number'.
Property 'toUpperCase' does not exist on type 'number'.

The solution is to narrow the union with code:

function printId(id: number | string) {
  if (typeof id === "string") {
    // In this branch, id is of type 'string'
    console.log(id.toUpperCase());
  } else {
    // Here, id is of type 'number'
    console.log(id);
  }
}

Intersection Types

Is mainly used to combine existing object types. Intersection type A & B means:

  • must have all members of A and B
  • must not have a member of another type
interface Colorful {
  color: string;
}
interface Circle {
  radius: number;
}

type ColorfulCircle = Colorful & Circle;

Type Alias and Interface

Creating a Type Alias allows you to reuse types. Instead of defining the type inline like here

function printCoord(pt: { x: number;  y: number }) {

}

we can create a Type Alias which allows us to reuse the type again somewhere else:

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

function printCoord(pt: Point) {

}

We could also use an interface instead:

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

What are the differences between Type Aliases and Interfaces?

The key distinction is that a type cannot be re-opened to add new properties vs an interface which is always extendable. Also, type aliases, unlike interfaces, can describe more than just object types, we can also use them to write other kinds of generic helper types:

type OrNull<Type> = Type | null;

type OneOrMany<Type> = Type | Type[];

type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>;
           
type OneOrManyOrNullStrings = OneOrManyOrNull<string>;

Extending an interface works:

interface Window {
  title: string
}

interface Window {
  ts: TypeScriptAPI
}

const src = 'const a = "Hello World"';
window.ts.transpileModule(src, {});

Extending a type does not work:

type Window = {
  title: string
}

type Window = {
  ts: TypeScriptAPI
}

 // Error: Duplicate identifier 'Window'.

Extending types

As explained previously, types cannot be extended, but interfaces can be:

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

interface AddressWithUnit extends BasicAddress {
  unit: string;
}

Type assertions

Sometimes you will have information about the type of a value that TypeScript can’t know about. In this situation, you can use a type assertion to specify a more specific type:

const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;

// or (does not work in tsx files)
const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas");

Literal types

There are three sets of literal types available in TypeScript today: strings, numbers, and booleans; by using literal types you can allow an exact value which a string, number, or boolean must have.

In the following example we only allow left, right or center as second parameter to printText. We enforce that by defining literal types:

function printText(s: string, alignment: "left" | "right" | "center") {
  // ...
}
printText("Hello, world", "left");
printText("G'day, mate", "centre");
// Argument of type '"centre"' is not assignable to parameter of type '"left" | "right" | "center"'.

In the following example req.method is inferred to be string, but the more specific type "GET" is expected. It is good that TypeScript considers this code to have an error because code between const req and handleRequest potentially could have changed req.method to something like "GUESS".

const req = { url: "https://example.com", method: "GET" };
handleRequest(req.url, req.method);

Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'.

There are two ways to work around this.

Option 1: You can change the inference by adding a type assertion in either location:

// Change 1:
const req = { url: "https://example.com", method: "GET" as "GET" };
// Change 2
handleRequest(req.url, req.method as "GET");

Option 2: You can use a as const assertion. This tells the compiler to infer the narrowest or most specific type it can for an expression.

const req = { url: "https://example.com", method: "GET" } as const;
handleRequest(req.url, req.method);

as const assertion

In addition to the previous example of using as const, here is another one:

const args = [8, 5];
// const args: number[]
const angle = Math.atan2(...args); // error! Expected 2 arguments, but got 0 or more.
console.log(angle);

The problem is that Math.atan2 requires 2 arguments, and we have indeed two arguments [8, 5], but the compiler correctly interprets [8, 5] as a number[] type. Hypothetically, we could have emptied the args array. What we need is to tell the compiler to treat the array as a const (i.e. readonly tupel) so it can assure that [8, 5] cannot be changed to [] for example. We do exactly that when writing:

const args = [8, 5] as const;
const angle = Math.atan2(...args); // works
console.log(angle);

Generic Types

TypeScript’s type system is very powerful because it allows expressing types in terms of other types. The simplest form of this idea is generics, but we actually have a wide variety of type operators available to us. It’s also possible to express types in terms of values that we already have.

By combining various type operators, we can express complex operations and values in a succinct, maintainable way.

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

// possible but not required to explicitly have <string> defined here
let output = identity<string>("myString");

// instead, TypeScript can infer the type itself
let output = identity("myString");

Instead of

interface NumberBox {
  contents: number;
}

interface StringBox {
  contents: string;
}

interface BooleanBox {
  contents: boolean;
}

better create a generic Box with a type parameter:

interface Box<Type> {
  contents: Type;
}

Then use it by specifying a type:

let box: Box<string>;

Think of Box as a template for a real type, where Type is a placeholder that will get replaced with some other type. When TypeScript sees Box<string>, it will replace every instance of Type in Box<Type> with string, and end up working with something like { contents: string }.

Having a generic type also means that we can avoid overloads entirely by instead using generic functions:

function setContents<Type>(box: Box<Type>, newContents: Type) {
  box.contents = newContents;
}

Array Type

// both type definitions are the same
const myArray: number[];
const myArray: Array<number>;

Modern JavaScript also provides other data structures which are generic, like Map<K, V>, Set<T>, and Promise<T>.

The ReadonlyArray is a special type that describes arrays that shouldn’t be changed.

// both type definitions are the same
const roArray: ReadonlyArray<string> = ["red", "green", "blue"];
const roArray: readonly string[] = ["red", "green", "blue"];

Non-null assertion operator

Only use ! when you know that the value can’t be null or undefined

function liveDangerously(x?: number | null) {
  // No error
  console.log(x!.toFixed());
}

Reference TS files

/// <reference path="person.ts" />

class Player implements Person {
  // ...
}

The never type

When narrowing, you can reduce the options of a union to a point where you have removed all possibilities and have nothing left. In those cases, TypeScript will use a never type to represent a state which shouldn’t exist.

Some functions never return a value:

function fail(msg: string): never {
  throw new Error(msg);
}

The unknown type

The unknown type represents any value. This is similar to the any type, but is safer because it’s not legal to do anything with an unknown value:

function f1(a: any) {
  a.b(); // OK
}
function f2(a: unknown) {
  a.b();
Object is of type 'unknown'.

}

You can describe a function that returns a value of unknown type:

function safeParse(s: string): unknown {
  return JSON.parse(s);
}

// Need to be careful with 'obj'!
const obj = safeParse(someRandomString);

The Function type

You should avoid untyped function calls like this, because of the unsafe any return type:

function doSomething(f: Function) {
  f(1, 2, 3);
}

If you need to accept an arbitrary function but don’t intend to call it, the type () => void is generally safer.

Basic Conditional Type

In this example you have a function setMeta which accepts either ImageLayer or TextLayer as parameter layer, and you have another parameter meta which accepts either ImageMeta or TextMeta. The problem is, that you can pass in ImageLayer as first parameter and TextMeta as second parameter, but what you want is that:

  • ImageLayer should only accept ImageMeta
  • TextLayer should only accept TextMeta
export interface ImageMeta {
  origin: string;
  format: "png" | "jpg";
}

export interface TextMeta {
  fontFoundry: string;
  licenseExpiration: Date;
}

function setMeta(layer: ImageLayer | TextLayer, meta: ImageMeta | TextMeta) {
    layer.meta = meta;
}

setMeta(textLayer, {
  format: "png",
  origin: ""
  // Problem: We passed ImageMeta to a TextLayer
  // but TypeScript does not complain, because setMeta function above allows this
});

You can solve this in two different ways, either via function overloading or via conditional types (preferred):

With function overloading:

function setMeta(layer: ImageLayer, meta: ImageMeta): void;
function setMeta(layer: TextLayer, meta: TextMeta): void;
function setMeta(layer: ImageLayer | TextLayer, meta: ImageMeta | TextMeta) {
    layer.meta = meta;
}

setMeta(textLayer, {
  format: "png",
  origin: ""
  // Good, now TypeScript complains that TextLayer was given ImageMeta
});
setMeta(textLayer, {
  fontFoundry: "something",
  licenseExpiration: new Date()
  // It works, but IntelliSense still offers ImageMeta (which we cannot use)
});

The preferred solution is to use Conditional Types. First remove the overloaded functions, then add:

function setMeta<T extends TextLayer | ImageLayer>(
    layer: T,
    meta: T extends TextLayer ? TextMeta : ImageMeta) {
  layer.meta = meta;
}

setMeta(imageLayer, {
  format: "png",
  origin: ""
  // Works, and also great: IntelliSense only offers ImageMeta properties here
  // If the first param was textLayer, then IntelliSense offered TextMeta properties here
});

Function Type Expressions

// The syntax (a: string) => void means “a function with one parameter, named a, of type string, that doesn’t have a return value”
function greeter(fn: (a: string) => void) {
  fn("Hello, World");
}

function printToConsole(s: string) {
  console.log(s);
}

greeter(printToConsole);

Call Signatures

In JavaScript Functions can be callable and can additionally have properties:

type DescribableFunction = {
  description: string;
  (someArg: number): boolean;
};
function doSomething(fn: DescribableFunction) {
  console.log(fn.description + " returned " + fn(6));
}

Note that the syntax is slightly different compared to a function type expression

Construct Signatures

You can write a construct signature by adding the new keyword in front of a call signature:

type SomeConstructor = {
  new (s: string): SomeObject;
};
function fn(ctor: SomeConstructor) {
  return new ctor("hello");
}

Index Signatures

Sometimes you don’t know all the names of a type’s properties ahead of time, but you do know the shape of the values. An index signature property type must be either ‘string’ or ‘number’.

interface Dictionary<T> {
  [key: string]: T;
}

Conditional return types

function createLayer(type: LayerType): TextLayer | ImageLayer {
  if (type === LayerType.Text) {
    return {
      color: "#fff",
      fontSize: "10px",
      id: new Date().toISOString(),
      maxWidth: 10000,
      position: { x: 10, y: 10 },
      rotation: 0,
      text: "This is the default text",
      type: LayerType.Text
    } as TextLayer;
  }

  return {
    id: new Date().toISOString(),
    maxBounds: { width: 400 },
    position: { x: 0, y: 0 },
    rotation: 0,
    src: "ps-dark.png",
    type: LayerType.Image
  } as ImageLayer;
}

const textLayer = createLayer(LayerType.Text);
// Problem: textLayer can be TextLayer or ImageLayer which (if ImageLayer) does not have text property
textLayer.text = "can't set this";

To fix this, we have to adjust the return type of createLayer function:

function createLayer<T extends LayerType>(type: T): T extends LayerType.Text ? TextLayer : ImageLayer {
  if (type === LayerType.Text) {
    return {
      color: "#fff",
      fontSize: "10px",
      id: new Date().toISOString(),
      maxWidth: 10000,
      position: { x: 10, y: 10 },
      rotation: 0,
      text: "This is the default text",
      type: LayerType.Text
    } as T extends LayerType.Text ? TextLayer : ImageLayer;
  }

  return {
    id: new Date().toISOString(),
    maxBounds: { width: 400 },
    position: { x: 0, y: 0 },
    rotation: 0,
    src: "ps-dark.png",
    type: LayerType.Image
  } as T extends LayerType.Text ? TextLayer : ImageLayer;
}

const textLayer = createLayer(LayerType.Text);
textLayer.text = "now it can be set";

Now it works. To make it cleaner, you can refactor out the conditional return type to its own type, in this case FactoryLayer:

type FactoryLayer<T> = T extends LayerType.Text ? TextLayer : ImageLayer;

function createLayer<T extends LayerType>(type: T): FactoryLayer<T> {
  if (type === LayerType.Text) {
    return {
      // ...
    } as FactoryLayer<T>;
  }

  return {
    // ...
  } as FactoryLayer<T>;
}

Lookup types

keyof

Lookup types, also referred to as indexed access types enable us to access the keys or the types for keys. Similar to how we can access the values of object properties, but for types.

type User = {
    id: number;
    name: string;
    place: string
}

type UserKeyTypes = User[keyof User];
// string | number

type UserKeys = keyof User;
// "id", "name", "place"

const works: UserKeys = "name";
const worksToo: UserKeys = "id";
const doesNotWork: UserKeys = 4711;

Mapping from one type to another

We want to ensure that you have all the properties of an existing type in another object:

const fieldDescriptions = {
  text: "This is the default text",
  anything: true // we don't want to allow this
};

Object.entries(fieldDescriptions).forEach(([field, description]) => {
  console.log(`${field}: ${description}`);
});

Solution 1:

type LayerCombined = TextLayer & ImageLayer;

type FieldDescriptions = {
  [key in keyof LayerCombined]: string;
}
const fieldDescriptions: FieldDescriptions = {
  text: "This is the default text",
  anything: true // Good, TypeScript complains that 'anything' does not exist in FieldDescriptions (that is: TextLayer)
  // Also good, TypeScript complains that other properties from FieldDescriptions (that is: TextLayer) are not specified here
};

Object.entries(fieldDescriptions).forEach(([field, description]) => {
  console.log(`${field}: ${description}`);
});

If you want to ensure all properties of two types, such as TextLayer and ImageLayer:

type FieldDescriptions = {
  [key in keyof (TextLayer & ImageLayer)]: string;
}

Only some of the properties of a type

Or in other words: All properties, except the ignored ones:

import { Project, TextLayer, ImageLayer, LayerType, Size } from "./types";

type LayerCombined = TextLayer & ImageLayer;
type IgnoredProperties = "id" | "maxBounds" | "position" | "meta";

type FieldDescriptions = {
  [key in Exclude<keyof LayerCombined, IgnoredProperties>]: string;
}

const fieldDescriptions: FieldDescriptions = {
  text: "This is the default text",
  // Good, typescript complains that 'anything' does not exist in TextLayer
};

Object.entries(fieldDescriptions).forEach(([field, description]) => {
  console.log(`${field}: ${description}`);
});

Mixins

If a class is getting too big and has too many responsibilities it should be split up. One way to do this is using Mixins. A Mixin is a function that returns a class. Let’s say you have a class ApiClient that has methods responsible for Projects and Images and you want to split out those methods to two mixins ProjectsAPI and ImagesAPI. But you still want to reference those methods through the class ApiClient:

# ProjectsAPI and ImagesAPI are functions (the Mixins) that each return a class
# Wrap them like this and you get both their functionalities
class ApiClient extends ProjectsAPI(ImagesAPI(CoreAPI)) {}

Now you can use methods related to Projects and Images in ApiClient. But the questions is of course, how do we code the Mixins themselves. This way:

export class CoreAPI {
  constructor(public baseUrl: string) {}
}

export type BaseAPIConstructor = new (...args: any[]) => CoreAPI;

function ImagesAPI<TBase extends BaseAPIConstructor>(Base: TBase) {
  return class extends Base {
    getImages(): Promise<{
      src: string;
      dimensions: Size;
    }> {
      return Promise.resolve({
        src: "http://a-fake-url.com/image-id",
        dimensions: {
          height: 100,
          width: 300
        }
      });
    }

    saveImage(src: string): Promise<number> {
      console.log(this.baseUrl);
      return Promise.resolve(10);
    }
  };
}

function ProjectsAPI<TBase extends BaseAPIConstructor>(Base: TBase) {
  return class extends Base {
    getProjects(): Promise<Project[]> {
      return Promise.resolve([]);
    }

    saveProject(): Promise<string> {
      return Promise.resolve("1");
    }

    deleteProject(): Promise<void> {
      return Promise.resolve();
    }
  };
}

Tuples

Tuples enable you to describe what type will be at what index:

type NumNumStrTuple = [number, number, string];

const myTuple: NumNumStrTuple = [4, 1, "hey"];
const myBadTuple: NumNumStrTuple = ["4", 1, "hey"]; // Error: "4" is not a number
const myOtherBadTuple: NumNumStrTuple = [4, 1]; // Error: missing string on third position

About Author

Mathias Bothe To my job profile

I am Mathias from Heidelberg, Germany. I am a passionate IT freelancer with 15+ years experience in programming, especially in developing web based applications for companies that range from small startups to the big players out there. I create Bosycom and initiated several software projects.