Originally published October 2018. Updated September 2020. This article describes the features and functionality of TypeScript 4.0. 

One of the most interesting languages for large-scale application development is Microsoft’s TypeScript. TypeScript is unique in that it is a superset of JavaScript, but with optional types, interfaces, generics, and more. Unlike other compile-to-JavaScript languages, TypeScript does not try to change JavaScript into a new language. Instead, the TypeScript team is careful to align the language’s extra features as closely as possible with what’s available in JavaScript, both current and draft features. Because of this, TypeScript developers are able to take advantage of the latest features in the JavaScript language in addition to a powerful type system to write better-organized code, all while taking advantage of the advanced tooling that using a statically typed language can provide.

Tooling support is where TypeScript really shines. Modular code and static types allow for better structured projects that are easier to maintain. This is especially important as JavaScript projects grow in size (both in terms of lines of code and developers on the project). Having fast, accurate completion, refactoring capabilities, and immediate feedback makes TypeScript the ideal language for large-scale JavaScript.

Getting started with TypeScript is easy! Since vanilla JavaScript is effectively TypeScript without type annotations, much or all of an existing project can be used immediately and then updated over time to take advantage of all that TypeScript has to offer.

While TypeScript’s documentation has improved significantly since this guide was first posted, this Definitive Guide still provides one of the best overviews of the key features of TypeScript, assuming you already have a reasonable knowledge of JavaScript. The guide is regularly updated to provide new information about the latest versions of TypeScript.

Installation and usage

Installing TypeScript is as simple as running npm install typescript. Once installed, the TypeScript compiler is available by running npx tsc. If you want to try out TypeScript in your browser, the TypeScript Playground lets you experience TypeScript with a full code editor, with the limitation that modules cannot be used. Most of the examples in this guide can be pasted directly into the playground to quickly see how TypeScript compiles into easy-to-read JavaScript.

From the command line, the compiler can run in a couple of different modes, selectable with compiler options. Just calling the executable will build the current project. Calling with --noEmit will type check the project but won’t emit any code. Adding a --watch option will start a server process that will continually watch a project and incrementally rebuild it whenever a file is changed, which can be much faster than performing a full compile from scratch. An --incremental flag was added in TS 3.4 that lets the compiler save some compiler states to a file, making subsequent full compiles faster (although not as fast as a watch-based rebuild).

Configuration

The TypeScript compiler is highly configurable, allowing the user to define where source files are located, how they should be transpiled, whether standard JavaScript files should be processed, and how strict the type checker should be. A tsconfig.json file identifies a project to the TypeScript compiler and contains settings used to build a TS project such as compiler flags. Most of the configuration options can also be passed directly to the tsc command. This is the tsconfig.json from the Dojo project’s framework package:

{
  "extends": "./node_modules/@dojo/scripts/tsconfig/umd.json",
  "compilerOptions": {
    "jsx": "react",
    "jsxFactory": "tsx",
    "types": [ "intern" ],
    "lib": [
      "dom",
      "es5",
      "es2015.core",
      "es2015.iterable",
      "es2015.promise",
      "es2015.symbol",
      "es2015.symbol.wellknown",
      "es2015.proxy"
    ]
  },
  "include": [
    "./src/**/*.ts",
    "./src/**/*.tsx",
    "./tests/**/*.ts",
    "./tests/**/*.tsx"
  ]
}

The extends property indicates that this file is extending another tsconfig.json file; much like extending a class, the settings in the file being extended are used as defaults, and the settings in the file doing the extending are overrides. The jsx property indicates that the project may use JSX syntax, and that JSX should be transformed into React-style JavaScript. The include option tells the compiler which files to include in the compilation.

TypeScript provides many options to control how the compiler works, such as the ability to relax type checking strictness or to allow vanilla JavaScript files to be processed. This is one of the best parts of TypeScript: it allows TypeScript to be added to an existing project without requiring that the entire project be converted to fully-typed TypeScript. For example, the noImplicitAny flag, when false, will prevent the compiler from emitting warnings about untyped variables. Over time, a project can disable this and enable stricter processing options, allowing a team to work up, incrementally, towards fully-typed code. For new TypeScript projects, it is recommended that the strict flag be enabled from the beginning to receive the full benefit of TypeScript.

Syntax and JavaScript support

TypeScript supports current JavaScript syntax (through ES2019), as well as a number of draft language proposals. In most cases, TypeScript can emit code that’s compatible with older JavaScript runtimes even when using new features, allowing developers to write code using modern JS features that can still run in legacy environments.

Proposed JavaScript features supported by TypeScript include:

There’s more to JavaScript than just syntax, though. TS also needs to understand the types used by the JavaScript standard library, which has changed over time. By default, the TS compiler emits ES3 code and assumes an ES3-compatible standard library. So, for example, arrays won’t have an include method. Setting the target compiler option to “es2015” (or “ES2015”; most TS option values aren’t case-sensitive) will instruct the compiler to emit ES2015 code, and also causes it to load several built-in ES2015 type libraries, one of which includes typings for Array.prototype.include.

Note that these are type libraries, not polyfills. They tell the compiler what features arrays will have in the target environment, but do not actually provide any functionality themselves.

The lib config property allows specific type subsets to be enabled to tailor the compiler’s output for a particular environment. For example, if a project will be running in a legacy environment that’s known to have a polyfill for  Array.prototype.include, then “es2016.array.include” could be added to the lib property to let the compiler know that this method (but not other ES2016 library methods) will be available. If code will be running in a browser, then “dom” should be added to lib to tell the compiler that global DOM resources will be available.

Different support libraries may also be used in specific files rather than the entire project with another TypeScript feature: triple slash directives. These are single-line comments containing XML tags that specify compiler directives, such as the lib setting. For example:

/// <reference lib="es2016.array.include" />

[ 'foo', 'bar', 'baz' ].includes('bar'); // true

The compiler will not throw an error about the use of Array.prototype.includes in the module containing the directive. However, if another file in the project tried to use includes, the compiler would throw an error. Note that not all compiler directives can be provided with triple slash directives, and also that these directives are only valid at the top of a TS file.

While TypeScript supports standard JavaScript syntax, it also adds some new syntax, such as type annotations, access modifiers (public, private), and support for generics. TS 3.4 added support for a new const assertion that can be used to declare values as deeply constant (unlike JavaScript’s const, which only declares a variable itself as unwritable). These differences are additive; they don’t replace normal JS syntax, but add new capabilities in a syntax-compatible fashion.

Imports and exports

TypeScript files use the .ts file extension, and each file typically represents a module, similar to AMD, CommonJS, and native JavaScript modules (ESM) files. TypeScript uses a relaxed version of the JavaScript import API to import and export resources from modules:

import myModule from './myModule';

The main difference from standard ESM imports is that TypeScript doesn’t require absolute URLs and file extensions when referencing modules. It will assume a .ts or .js file extension, and uses a couple of different module resolution strategies to locate modules.

For AMD, SystemJS, and ES2015 modules, TypeScript defaults to its “classic” strategy. For any other module type it defaults to its “node” strategy. The strategy can be manually set with the moduleResolution config option.

When the classic strategy is in use, relative module IDs are resolved relative to the directory containing the referencing module. For absolute module IDs, the compiler walks up the filesystem, starting from the directory containing the referencing module, looking for .ts, then .d.ts, in each parent directory, until it finds a match.

The node strategy uses Node’s module resolution logic. Relative module IDs are resolved relative to the directory containing the referencing module, and will consider the “main” field in a package.json if present. Absolute module IDs are resolved by first looking for the referenced module in a local node_modules directory, and then by walking up the directory hierarchy, looking for the module in node_modules directories.

In both strategies, the baseUrl, paths, and rootDirs options can be used to further configure where the compiler looks for absolutely-referenced modules.

TypeScript can emulate ESM’s default import semantics when working with legacy module formats like CommonJS. Enabling the esModuleInterop flag will make the compiler emit code allowing default imports to work for legacy modules that don’t technically have default exports.

Basic types

Types are the banner feature of TypeScript. The TS compiler determines a type for every value (variable, function argument, return value, etc.) in a program, and it uses these types for a range of features, from indicating when a function is being called with the wrong input to enabling an IDE to auto-complete a class property name.

Without additional type hints, all variables in TypeScript have the any type, meaning they are allowed to contain any type of data, just like a JavaScript variable. The basic syntax for adding type constraints to code in TypeScript looks like this:

function toNumber(numberString: string): number {
  const num: number = parseFloat(numberString);
  return num;
}

The bolded type hints in the code above indicate that toNumber accepts one string parameter, and that it returns a number. The variable num is also explicitly typed to contain a number. Note that in many cases explicit type hints are not required (although it still may be beneficial to provide them) because TypeScript can infer them from the code itself. For example, the number type could be left off of the num declaration, because the TS compiler knows that parseFloat returns a number. Similarly, the number return type isn’t required because the compiler knows that the function always returns a number.

The primitive types that TypeScript provides match the primitive types of JavaScript itself: any, number, string, boolean. TypeScript also has void (for null or undefined function return values), never, and as of TypeScript 3.0, unknown.

In most cases, never is inferred for functions where the compiler detects unreachable code, so developers won’t often use never directly. For example, if a function only throws, it will have a return type of never.

unknown is the type-safe counterpart of any; anything can be assigned to an unknown variable, but an unknown value can’t be assigned to anything other than an any variable without a type assertion or type narrowing.

When writing an expression (function call, arithmetic operation, etc.), you can also explicitly indicate the resulting type of the expression with a type assertion, which is necessary if you are calling a function where TypeScript cannot figure out the return type automatically. For example:

function numberStringSwap(value: any, radix: number = 10): any {
  if (typeof value === 'string') {
    return parseInt(value, radix);
  } else if (typeof value === 'number') {
    return String(value);
  }
}   

const num = numberStringSwap('1234') as number;
const str = <string> numberStringSwap(1234);

In this example, the return value of numberStringSwap has been declared as any because the function might return more than one type. In order to remove the ambiguity, the type of the expression being assigned to num is explicitly asserted by the as number modifier after the call to numberStringSwap.

Type assertions must be made to compatible types. If TypeScript knew that numberStringSwap returned a string on line 10, attempting to assert that the value was a number would result in a compiler error (“Cannot convert string to number”) since the two types are known to be incompatible.

There is also a legacy syntax for type-casting that uses angle brackets (<>), as shown in line 11 above. The semantics for using angle brackets is the same as for using as. This used to be the default syntax, but it was replaced by as due to conflicts with JSX syntax (more on that later).

When writing code in TypeScript, it is a good practice to explicitly add types to your variables and functions when types cannot be inferred, or when you want to ensure a certain type (such as a function return type), or just for documentation. When a variable is not annotated and the type cannot be inferred, it is given an implicit any type. The noImplicitAny compiler option can be set in the tsconfig.json or on the command line and will prevent any accidental implicit any types from sneaking into your code.

TypeScript also has support for string literal types. These are useful when you know that the value of a parameter can match one of a list of strings, for example:

let easing: "ease-in" | "ease-out" | "ease-in-out";

The compiler will check that any assignment to easing has one of the three values: ease-in, ease-out, or ease-in-out.

Object types

In addition to the primitive types, TypeScript allows complex types (like objects and functions) to be easily defined and used in type constraints. Just as object literals are at the root of most object definitions in JavaScript, the object type literal is at the root of most object type definitions in TypeScript. In its most basic form, it looks very similar to a normal JavaScript object literal:

let point: {
  x: number;
  y: number;
};

In this example, the point variable is defined as accepting any object with numeric x and y properties. Note that, unlike a normal object literal, the object type literal separates fields using semicolons, not commas.

TypeScript also includes an object type, which represents any non-primitive value (i.e., not a number, string, etc.). This type is distinct from Object, which can represent any JavaScript type (including primitives). For example, Object.create‘s first argument must be an object (a non-primitive) or null. If this argument is typed as an Object, TypeScript will allow primitive values to be passed to Object.create, which would cause a runtime error. When the argument is typed as an object, TypeScript will only allow non-primitive values to be used. The object type is also distinct from object type literals since it doesn’t specify any structure for an object.

When TypeScript compares two different object types to decide whether or not they match, it does so structurally. This means that instead of checking whether two values both inherit from a shared ancestor type, as typing checking in many other languages does, the compiler instead compares the properties of each object to see if they are compatible. If an object being assigned has all of the properties that are required by the constraint on the variable being assigned to, and the property types are compatible, then the two types are considered compatible:

let point: { x: number; y: number; };

// OK, properties match
point = { x: 0, y: 0 };

// Error, x property type is wrong
point = { x: 'zero', y: 0 };

// Error, missing required property y
point = { x: 0 };

// Error, object literal may only specify known properties
point = { x: 0, y: 0, z: 0 };

const otherPoint = { x: 0, y: 0, z: 0 };

// OK, extra properties not relevant for non-literal assignment
point = otherPoint;

Note the error when assigning a literal object with an extra property.  Literal values are checked more strictly than non-literals.

In order to reduce type duplication, the typeof operator can be used to reference the type of a value. For instance, if we were to add a point2 variable, instead of having to write this:

let point: { x: number; y: number; };
let point2: { x: number; y: number; };

We could instead simply reference the type of point using typeof:

let point: { x: number; y: number; };
let point2: typeof point;

This mechanism helps to reduce the amount of code we need to reference the same type, but there is another even more powerful abstraction in TypeScript for reusing object types: interfaces. An interface is, in essence, a named object type literal. Changing the previous example to use an interface would look like this:

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

let point: Point;
let point2: Point;

This change allows the Point type to be used in multiple places within the code without having to redefine the type’s details over and over again. Interfaces can also extend other interfaces or classes using the extends keyword in order to compose more complex types out of simple types:

interface Point3d extends Point {
  z: number;
}

In this example, the resulting Point3d type would consist of the x and y properties of the Point interface, plus the new z property.

Methods and properties on objects can also be specified as optional, in the same way that function parameters can be made optional:

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

Here, instead of specifying a separate interface for a three-dimensional point, we simply make the z property of the interface optional; the resulting type checking would look like this:

let point: Point;

// OK, properties match
point = { x: 0, y: 0, z: 0 };

// OK, properties match, optional property missing
point = { x: 0, y: 0 };

// Error, `z` property type is wrong
point = { x: 0, y: 0, z: 'zero' };

So far, we’ve looked at object types with properties, but haven’t specified how to add a method to an object. Because functions are first-class objects in JavaScript, they can be typed like any other object property (we’ll talk more about functions later):

interface Point {
  x: number;
  y: number;
  z?: number;

  toGeo: () => Point;
}

Here we’ve declared a toGeo property on Point with the type () => Point (a function that takes no arguments and returns a Point). TypeScript also provides a shorthand syntax for specifying methods, which becomes very convenient later when we start working with classes:

interface Point {
  x: number;
  y: number;
  z?: number;

  toGeo(): Point;
}

Like properties, methods can also be made optional by putting a question mark after the method name:

interface Point {
  // ...
  toGeo?(): Point;
}

Objects that are intended to be used as hash maps or ordered lists can be given an index signature, which enables arbitrary keys to be defined on an object:

interface HashMapOfPoints {
  [key: string]: Point;
}

In this example, we’ve defined a type where arbitrary string keys can be set, so long as the assigned value is of type Point. As in JavaScript, it is only possible to use string or number as the type of the index signature.

For object types without an index signature, TypeScript will only allow properties to be set that are explicitly defined on the type. If you try to assign to a property that doesn’t exist on the type, you will get a compiler error. Occasionally, though, you do want to add dynamic properties to an object without an index signature. To do so, you can simply use array notation to set the property on the object: a['foo'] = 'foo'. Note, however, that using this workaround defeats the type system for these properties, so only do this as a last resort.

Interface properties can also be named using constant values, similar to computed property names on normal objects. Computed values must be constant strings, numbers, or Symbols:

const Foo = 'Foo';
const Bar = 'Bar';
const Baz = Symbol();

interface MyInterface {
  [Foo]: number;
  [Bar]: string;
  [Baz]: boolean;
}

Tuple types

While JavaScript itself doesn’t have tuples, TypeScript makes it possible to emulate typed tuples using arrays. If you wanted to store a point as an (x, y, z) tuple instead of as an object, this can be done by specifying a tuple type on a variable:

let point: [ number, number, number ] = [ 0, 0, 0 ];

TypeScript 3.0 improved support for tuple types by allowing them to be used with rest and spread expressions, and by allowing for optional elements.

function draw(...point: [ number, number, number? ]): void {
  const [ x, y, z ] = point;
  console.log('point', ...point);
}

draw(100, 200);         // logs: point 100, 200
draw(100, 200, 75);     // logs: point 100, 200, 75
draw(100, 200, 75, 25); // Error: Expected 2-3 arguments but got 4

In the above example, the draw function can accept values for x, y, and optionally z. TypeScript 4.0 further enhanced tuple types by allowing for variable length tuple types and labeled tuple elements.

let point: [x: number, y: number, z: number] = [0,0,0];

function concat<T, U>(arr1: T[], arr2: U[]): Array<T | U> {
    return [...arr1, ...arr2];
}

The above example uses labeled tuples to make the point type more readable, and shows an example of using variadic tuple types to write more concise types for functions that work on general tuple types.

Function types

Function types are typically defined using arrow syntax:

let printPoint: (point: Point) => string;

Here the variable printPoint is described as accepting a function that takes a Point argument and returns a string. The same syntax is used to describe a function argument to another function:

let printPoint: (getPoint: () => Point) => string;

Note the use of the arrow (=>) to define the return type of the function. This differs from how the return type is written in a function declaration, where a colon (:) is used:

function printPoint(point: Point): string { ... }
const printPoint = (point: Point): string => { ... }

This can be a bit confusing at first, but as you work with TypeScript, you will find it is easy to know when one or the other should be used. For instance, in the original printPoint example, using a colon would look wrong because it would result in two colons directly within the constraint:

let printPoint: (point: Point): string

Similarly, using an arrow with an arrow function would look wrong:

const printPoint = (point: Point) => string => { ... }

Functions can also be described using the object literal syntax:

let printPoint: { (point: Point): string; };

This is effectively describing printPoint as a callable object (which is what a JavaScript function is).

Functions can be typed as constructors by putting the new keyword before the function type:

let Point: { new (): Point; };
let Point: new () => Point;

In this example, any function assigned to Point would need to be a constructor that creates Point objects.

Because the object literal syntax allows us to define objects as functions, it’s also possible to define function types with static properties or methods (like the JavaScript String function, which also has a static method String.fromCharCode):

let Point: {
  new (): Point;
  fromLinear(point: Point): Point;
  fromGeo(point: Point): Point;
};

Here, we’ve defined Point as a constructor that also needs to have static Point.fromLinear and Point.fromGeo methods. The only way to actually do this is to define a class that implements Point and has static fromLinear and fromGeo methods; we’ll look at how to do this later when we discuss classes in depth.

As of TypeScript 3.1, static fields may also be added to functions simply by assigning to them:

function createPoint(x: number, y: number) {
  return new Point(x, y);
}

createPoint.print(point: Point): string {
  // print a point
}

Point p = createPoint(1, 2);

createPoint.print(p); // prints the point

Overloaded functions

Earlier, we created an example numberStringSwap function that converts between numbers and strings:

function numberStringSwap(value: any, radix: number): any {
  if (typeof value === 'string') {
    return parseInt(value, radix);
  } else if (typeof value === 'number') {
    return String(value);
  }
}

We know that this function returns a string when it is passed a number, and a number when it is passed a string. However, the call signature doesn’t indicate this — since any is used for the value and return types, TypeScript doesn’t know what specific types of values are acceptable or what type will be returned. We can use function overloads to let the compiler know more about how the function actually works.

One way to write the above function, in which typing is correctly handled, is:

function numberStringSwap(value: number, radix?: number): string;
function numberStringSwap(value: string): number;
function numberStringSwap(value: any, radix: number = 10): any {
  if (typeof value === 'string') {
    return parseInt(value, radix);
  } else if (typeof value === 'number') {
    return String(value);
  }
}

With the above types, TypeScript now knows that the function can be called in two ways: with a number and optional radix, or with a string. If it’s called with a number, it will return a string, and vice versa. You can also use union types in some cases instead of function overloads, which will be discussed later in this guide.

It is extremely important to keep in mind that the concrete function implementation must have an interface that matches the lowest common denominator of all of the overload signatures. This means that if a parameter accepts multiple types, as value does here, the concrete implementation must specify a type that encompasses all the possible options. In the case of numberStringSwap, because string and number have no common base, the type for value must be any (or a union type).

Similarly, if different overloads accept different numbers of arguments, any arguments that do not exist in all overload signatures must be optional in the concrete implementation. For numberStringSwap, this means that we have to make the radix argument optional in the concrete implementation. This was done by specifying a default value for radix.

Not following these rules will result in a generic “Overload signature is not compatible with function definition” error.

Note that even though our fully defined function uses the any type for value, attempting to pass another type (like a boolean) for this parameter will cause TypeScript to throw an error because only the overloaded signatures are used for type checking. In a case where more than one signature would match a given call, the first overload listed in the source code will win:

function numberStringSwap(value: any): any;
function numberStringSwap(value: number): string;

numberStringSwap('1234');

Here, even though the second overload signature is more specific, the first will be used. This means that you always need to make sure your source code is ordered so more specific overloads won’t be shadowed by more general ones.

Function overloads also work within object type literals, interfaces, and classes:

let numberStringSwap: {
  (value: number, radix?: number): string;
  (value: string): number;
};

Note that because we are defining a type and not creating an actual function declaration, the concrete implementation of numberStringSwap is omitted.

TypeScript also allows you to specify different return types when an exact string is provided as an argument to a function. For example, the DOM createElement method could be typed like this:

createElement(tagName: 'a'): HTMLAnchorElement;
createElement(tagName: 'abbr'): HTMLElement;
createElement(tagName: 'address'): HTMLElement;
createElement(tagName: 'area'): HTMLAreaElement;
// ... etc.
createElement(tagName: string): HTMLElement;

This would let TypeScript know that when, say createElement('video') is called, the return value will be an HTMLVideoElement, whereas when createElement('a') is called, the return value will be an HTMLAnchorElement.

Strict function types

By default, TypeScript is a bit lax when checking function type parameters. Consider the following example:

class Animal { breathe() { } }
class Dog extends Animal { bark() {} }
class Cat extends Animal { meow() {} }
   
let f1: (x: Animal) => void = (x: Animal) => x.breathe();
let f2: (x: Dog) => void = (x: Dog) => x.bark();
let f3: (x: Cat) => void = (x: Cat) => x.meow();
   
f1 = f2;
const c = new Cat();
f1(c); // Runtime error

Dog is a type of animal, so the assignment f1 = f2 is valid. However, now f1 is a function that can only accept Dogs, even though its type says it can accept any Animal. Trying to call f1 on a Cat will generate a runtime error when the function tries to call bark on it.

TypeScript allows this situation because function arguments in TypeScript are bivariant, which is unsound (as far as typing is concerned). The strictFunctionTypes compiler option can be enabled to flag this kind of unsound assignment.

Rest Parameters

Some functions may take an unspecified number of parameters. TypeScript allows expressing these using a rest parameter. For example, Array.push takes one or more parameters of the same type as the array. The example below shows the type for this function.

interface Array<T> {
    push(...args: T[]): number;
}

Without the use of rest parameters you would need to write an overload for every number of arguments that the function needs to accept.

Generic types

TypeScript includes the concept of a generic type, which can be roughly thought of as a type that must include or reference another type in order to be complete. Two generic types that you’ve probably already used are Array and Promise.

The syntax of a generic value type is GenericType<SpecificType>. For example, an “array of strings” type would be Array<string>, and a “promise that resolves to a number” type would be Promise<number>. Generic types may require more than one specific type, like Converter<TInput, TOutput>, but this is uncommon. The placeholder types inside the angle brackets are called type parameters.

To explain how to create your own generic types, consider how an Array-like class might be typed:

interface Arrayish<T> {
  map<U>(
    callback: (value: T, index: number, array: Arrayish<T>) => U,
    thisArg?: any
  ): Array<U>;
}

In this example, Arrayish is defined as a generic type with a single map method, which corresponds to the Array#map method from ECMAScript 5. The map method has a type parameter of its own, U, which is used to indicate that the return type of the callback function needs to be the same as the return type of the map call.

Actually using this type would look something like this:

const arrayOfStrings: Arrayish<string> = [ 'a', 'b', 'c' ];
const arrayOfCharCodes: Arrayish<number> =
  arrayOfStrings.map(value => value.charCodeAt(0));

Here, arrayOfStrings is defined as being an Arrayish containing strings, and arrayOfCharCodes is defined as being an Arrayish containing numbers. We call map on the array of strings, passing a callback function that returns numbers. If the callback returned a string instead of a number, the compiler would raise an error that the types were not compatible, because arrayOfCharCodes is explicitly typed.

Because arrays are an exceptionally common generic type, TypeScript provides a shorthand notation: SpecificType[]. Note, however, ambiguity can occasionally arise when using this shorthand. For example, is the type () => boolean[] an array of functions that return booleans, or is it a single function that returns an array of booleans? The answer is the latter; to represent the former, you would typically write (() => boolean)[].

TypeScript also allows type parameters to be constrained to a specific type by using the extends keyword within the type parameter, like interface PointPromise. In this case, only a type that structurally matched Point could be used for T; trying to use something else, like string, would cause a type error.

Generic types may be given defaults, which can reduce boilerplate in many instances. For example, if we wanted a function that created an Arrayish based on the arguments passed but defaulted to string when no arguments are passed, we would write:

function createArrayish(...args: T[]): Arrayish {
  return args;
}

Union types

Union types allow a parameter or variable to support more than one type. For example, if you wanted to have a convenience function like document.getElementById that could accept either a string ID or an element, like Dojo’s byId function, you could do this using a union type:

function byId(element: string | Element): Element {
  if (typeof element === 'string') {
    return document.getElementById(element);
  } else {
    return element;
  }
}

TypeScript is intelligent enough to contextually type the element variable inside the if block to be of type string, and to be of type Element in the else block. Code used to narrow types is referred to as a type guard; these will be discussed in more detail later in this article.

Intersection types

While union types indicate that a value may be one type or another, intersection types indicate that a value will be a combination of multiple types; it must meet the contract of all of the member types. For example:

interface Foo {
  name: string;
  count: number;
}

interface Bar {
  name: string;
  age: number;
}

export type FooBar = Foo & Bar;

A value of type FooBar must have name, count, and age properties.

TypeScript doesn’t require overlapping properties to have compatible types, so it’s possible to make unusable types:

interface Foo {
  count: string;
}

interface Bar {
  count: number;
}

export type FooBar2 = Foo & Bar;

The count property in FooBar2 is of type never since a value can’t be both a string and a number, meaning no value can be assigned to it.

Type aliases

We saw earlier that typeof and interfaces were two ways to avoid having to code the full type of a value everywhere it’s needed. Another way to accomplish this is with type aliases. A type alias is just a reference to a specific type.

import * as foo from './foo';
type Foo = foo.Foo;
type Bar = () => string;
type StringOrNumber = string | number;
type PromiseOrValue<T> = T | Promise<T>;
type BarkingAnimal = Animal & { bark(): void };

Type aliases are very similar to interfaces. They can be extended using the intersection operator, as with the BarkingAnimal type shown above. They can also be used as the base type for interfaces (except for aliases to union types).

Unlike interfaces, aliases aren’t subject to declaration merging. When an interface is defined multiple times in a single scope, the declarations will be merged into a single interface. A type alias, on the other hand, is a named entity, like a variable. As with variables, type declarations are block scoped, and you can’t declare two types with the same name in the same scope.

Mapped types

Mapped types allow for the creation of new types based on existing types by mapping properties of an existing type to a new type. Consider the type Stringify below; Stringify will have all the same properties as T, but those properties will all have values of type string.

type Stringify<T> = {
  [P in keyof T]: string;
};

interface Point { x: number; y: number; }
type StringPoint = Stringify<Point>;
const pointA: StringPoint = { x: '4', Y: '3' }; // valid

Note that mapped types only affect types, not values; the Stringify type above won’t actually transform an object of arbitrary values into an object of strings.

TypeScript 2.8 added the ability to add or remove readonly or ? modifiers from mapped properties. This is done using + and – to indicate whether the modifier should be added or removed.

type MutableRequired<T> = { -readonly [P in keyof T]-?: T[P] };
type ReadonlyPartial<T> = { +readonly [P in keyof T]+?: T[P] };
   
interface Point { readonly x: number; y: number; }
const pointA: ReadonlyPartial<Point> = { x: 4 };
pointA.y = 3; // Error: readonly
const pointB: MutableRequired<Point> = { x: 4, y: 3 };
pointB.x = 2; // valid

In the example above, MutableRequired makes all properties of its source type non-optional and writable, whereas ReadonlyPartial makes all properties optional and readonly.

TypeScript 3.1 introduced the ability to map over a tuple type and return a new tuple type. Consider the following example where a tuple type Point is defined. Suppose that in some cases points will actually be Promises that resolve to Point objects. TypeScript allows for the creation of the latter type from the former:

type ToPromise<T> = { [K in typeof T]: Promise<T[K]> };
type Point = [ number, number ];
type PromisePoint = ToPromise<Point>;
const point: PromisePoint =
  [ Promise.resolve(2), Promise.resolve(3) ]; // valid

Certain mapped type patterns are so common that they’ve become built-in types in TypeScript:

  • Partial<T> – constructs a type with all the properties of T set to optional
  • Required<T> – constructs a type with all the properties of T set to required
  • Readonly<T> – constructs a type with all the properties of T set to readonly
  • Record<K, T> – constructs a type with property names from K, where each property has type T
  • Pick<T, K> – constructs a type with just the properties from T specified by K
  • Omit<T, K> – constructs a type with all the properties from T except those specified by K

Conditional types

Conditional types allow for a type to be set dynamically based on a provided condition. All conditional types follow the same format: T extends U ? X : Y. This may look familiar since it uses the same syntax as a JavaScript ternary statement. What this statement means is that if T is assignable to U, then set the type to X. Otherwise, set the type to Y.

This may seem like a very simple concept, but it can dramatically simplify complex typings. Consider the following example where we would like to define types for a function that accepts either a number or a string.

declare function addOrConcat(x: number | string): number | string;

The types here are fine but they do not truly convey the meaning or intent of the code. Presumably, if the argument is a number then the return type will also be number, and likewise with string. To correct this, we can use function overloading

declare function addOrConcat(x: string): string;
declare function addOrConcat(x: number): number;
declare function addOrConcat(x: number | string): number | string;

However this is a little verbose and can be tedious to change in the future. Enter conditional types!

declare function addOrConcat<T extends number | string>(x: T): T extends number ? number : string;

This function signature is generic, stating that T will either be a number or a string. A conditional type is used to determine the return type; if the function argument is a number, the function return type is number, otherwise it’s string.

The infer keyword can be used in conditional types to introduce a type variable that the TypeScript compiler will infer from its context. For example, you could write a function that infers the type of a tuple from its members and returns the first element as that type.

function first<T extends [any, any]>(pair: T): T extends [infer U, infer U] ? U : any {
    return pair[0];
}

first([3, 'foo']); // Type will be string | number
first([0, 0]); // Type will be number

Type guards

Type guards allow for narrowing of types within a conditional block. This is essential when working with types that could be unions of two or more types, or where the type is not known until runtime. To do this in a way that is also compatible with the JavaScript code that will be run at runtime, the type system ties into the typeof, instanceof, and in (as of TS 2.7) operators. Inside of a conditional block using one of these checks, it is guaranteed that the value checked is of the specified type, and methods that would exist on that type can be used safely.

typeof and instanceof

TypeScript will use the JavaScript typeof and instanceof operators as type guards.

function lower(x: string | string[]) {
  if (typeof x === 'string') {
  // x is guaranteed to be a string, so we can use toLowerCase

  return x.toLowerCase();
} else {
  // x is definitely an array of strings, so we can use reduce
  return x.reduce(
      (val: string, next: string) => val += `, ${next.toLowerCase()}`, '');
  }
}

function clearElement(element: string | HTMLElement) {
  if (element instanceof HTMLElement) {
    // element is guaranteed to be an HTMLElement in here
    // so we can access its innerHTML property
    element.innerHTML = '';
  } else {
    // element is a string in here so we can pass that to querySelector
    const el = document.querySelector(element);
    el && el.innerHTML = '';
  }
}

TypeScript understands, based on the result of a typeof or instanceof check, what the type of x must be in each part of an if/else statement.

in

This type guard narrows the type within a conditional by checking if a property exists on the variable. If the result is true, the variable type will be narrowed to match the type that contains the value checked on.

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

interface Point3d extends Point {
  z: number;
}

function plot(point: Point) {
  if ('z' in point) {
    // point is a Point3D
  } else {
    // point is a Point
  }
}

Type predicates

You can also create functions that return type predicates, explicitly indicating the type of a value.

function isDog(animal: Animal): animal is Dog {
  return typeof (animal as Dog).bark === 'function';
}

if (isDog(someAnimal)) {
  someAnimal.bark(); // valid
}

The predicate animal is Dog says that if the function returns true, then the function’s argument is explicitly of type Dog.

Classes

For the most part, classes in TypeScript are similar to classes in standard JavaScript, but there are a few differences to allow classes to be properly typed.

TypeScript allows class fields to be explicitly declared so that the compiler will know what properties are valid for a class. Class fields can also be declared as protected and private, and as of TS 3.8 may also use ECMAScript private fields.

class Animal {
  protected _happy: boolean;
  name: string;
  #secretId: number;
    
  constructor(name: string) {
    this.name = name;
    this.#secretId = Math.random();
  }

  pet(): void {
    this._happy = true;
  }
}

Note that TypeScript’s private modifier is not related to ECMAScript private fields, which are denoted with a hash sign (e.g., #privateField). Private TS fields are only private during compilation; at runtime they are accessible just like any normal class field. This is why the JavaScript convention of prefixing private fields with underscores is still commonly seen in TS code. ECMAScript private fields, on the other hand, have “hard” privacy, and are completely inaccessible outside of a class at runtime.

TypeScript also allows class fields to use a static modifier, which indicates that they are actually properties on the class itself rather than instance properties (on the class’s prototype).

class Dog extends Animal {
  static isDogLike(object: any): object is Dog {
    return object.bark && object.pet;
  }
}

if (Dog.isDogLike(someAnimal)) {
  someAnimal.bark();
}

Properties may be declared readonly to indicate that they can only be set when an object is created. This is essentially const for object properties.

class Dog extends Animal {
  readonly breed: string;
    
  constructor(name: string, breed: string) {
    super(name);
    this.breed = breed;
  }
}

Classes also support getters and setters for properties. A getter lets you compute a value to return as the property value, while a setter lets you run arbitrary code when the property is set. For example the above animal class could be extended with a status getter that derives a status message from its other properties.

class Animal {
  protected _happy: boolean;
  name: string;
  #secretId: number;
    
  constructor(name: string) {
    this.name = name;
    this.#secretId = Math.random();
  }

  pet(): void {
    this._happy = true;
  }

  get status(): string {
    return `${this.name} ${this._happy ? 'is' : 'is not'} happy`;  
  }
}
const animal = new Animal('Spike');
const status = animal.status; // status = 'Spike is not happy';

Properties may also be initialized in a class definition. The initial value of a property can be any assignment expression, not just a static value, and will be executed every time a new instance is created:

class DomesticatedDog extends Dog {
  age = Math.random() * 20;
  collarType = 'leather';
  toys: Toy[] = [];
}

Since initializers are executed for each new instance, you don’t have to worry about objects or arrays being shared across instances as you would if they were specified on an object prototype, which alleviates a common point of confusion for people using JavaScript “class-like” inheritance libraries that specify properties on the prototype.

When using constructors, properties may be declared and initialized through the constructor definition by prefixing parameters with an access modifier and/or readonly:

class DomesticatedDog extends Dog {
  toys: Toy[] = [];

  constructor(
    public name: string,
    readonly public age: number,
    public collarType: string
  ) { }
}

Here the name, age, and collarType constructor parameters will become class properties, and will be initialized with the parameter values.

As of TypeScript 4.0, class property types can also be inferred from their assignments in the constructor. Take the following example:

class Animal {
 sharpTeeth; // <-- no type here! 😱
 constructor(fangs = 2) {
  this.sharpTeeth = fangs;
 }
}

Prior to TypeScript 4.0, this would cause sharpTeeth to be typed as any (or as an error if using a strict option). Now, however, TypeScript can infer that sharpTeeth is the same type as fangs, which is a number.

Typing this

TypeScript can infer the type of this in normal class methods. In places where it can’t be inferred, such as nested functions, this will default to the any type. The type of this can be specified by providing a fake first parameter in a function type.

class Dog {
  name: string;
  bark: () => void;

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

  createBarkFunction() {
    return function(this: Dog) {
      console.log(`${this.name} says hi!`);
    }
  }
}

Setting the noImplicitThis compiler flag will cause TypeScript to emit a compiler error whenever this would default to the any type.

Multiple inheritance and mixins

In TypeScript, interfaces can extend other interfaces and classes, which can be useful when composing complex types, especially if you are used to writing mixins and using multiple inheritance:

interface Chimera extends Dog, Lion, Monsterish {}

class MyChimera implements Chimera {
  bark: () => string;
  roar: () => string;
  terrorize(): void {
    // ...
  }
  // ...
}

MyChimera.prototype.bark = Dog.prototype.bark;
MyChimera.prototype.roar = Lion.prototype.roar;

In this example, two classes (Dog and Lion) and an interface (Monsterish) have been combined into a new Chimera interface. The MyChimera class implements that interface, delegating back to the original classes for function implementations. Note that the bark and roar methods are actually defined as properties rather than methods; this allows the interface to be “fully implemented” by the class despite the concrete implementation not actually existing within the class definition. This is one of the more advanced use cases for classes in TypeScript, but it enables extremely robust and efficient code reuse when used properly.

TypeScript is also able to handle typings for ES2015 mixin classes. A mixin is a function that takes a constructor and returns a new class (the mixin class) that is an extension of the constructor.

class Dog extends Animal {
  name: string;

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

type Constructor<T = {}> = new (...args: any[]) => T;

function canRollOver<T extends Constructor>(Animal: T) {
  return class extends Animal {
    rollOver() {
      console.log("rolled over");
    }
  }
}

const TrainedDog = canRollOver(Dog);
const rover = new TrainedDog("Rover");

rover.rollOver();  // valid
rover.rollsOver(); // Error: Property 'rollsOver' does not exist on type ...

The type of rover will be Dog & (mixin class), which is effectively Dog with a rollOver method.

Enums

TypeScript includes an enum type that allows for efficient representation of sets of constant values. For example, from the TypeScript specification, an enumeration of possible styles to apply to text might look like this:

enum Style {
  NONE = 0,
  BOLD = 1,
  ITALIC = 2,
  UNDERLINE = 4,
  EMPHASIS = Style.BOLD | Style.ITALIC,
  HYPERLINK = Style.BOLD | Style.UNDERLINE
}

Enums can be initialized with constants or via computed values, or they can be auto-initialized, or a mix of initializations. Note that auto-initialized entries must come before entries initialized with computed values.

enum Directions {
  North, // will have value 0
  South, // will have value 1
  East = getDirectionValue(),
  West = 10
}

Enum values can also be strings, or a mix of numbers and strings.

enum Color {
  Red = "RED",
  Green = "GREEN",
  Blue = "BLUE"
}

Numeric enums are two-way maps, so you can determine the name of an enumerated value by looking it up in the enum object. For example, using the Style above example, Style[1] would evaluate to ‘BOLD’. String-initialized enums cannot be reverse mapped.

Enums are real objects, not just typing constructs, so they exist at runtime, and incur some runtime cost. That isn’t normally a problem, but for cases where constraints are tight, const enum may help. When const is applied to enum, the compiler will replace all uses of the enum with literal values at compile time, so that no runtime cost is incurred. Note that all entries in a const enum must be auto-initialized or be initialized with constant expressions (no computed values).

Ambient declarations

Statically typed code is great, but there are still some libraries that don’t include typings. TypeScript can work with these out of the box, but without the full benefit of typed code. Luckily, TypeScript also has a mechanism for adding types to legacy and/or external code: ambient declarations.

Ambient declarations describe the types, or “shape”, of existing code, but don’t provide an implementation. Various constructs, such as variables, classes, and functions, can be declared using the keyword declare. For example, the global variable installed by jQuery is defined in the jQuery typings on DefinitelyTyped (a public repository of third party typings for JavaScript packages) as:

declare const jQuery: JQueryStatic;
declare const $: JQueryStatic;

When these typings are included in a project, TypeScript will understand that there are jQuery and $ global variables with the type JQueryStatic.

One of the most common use cases for ambient types is to provide typings for entire modules or packages. For example, assume we have a “vetUtils” package that exports some classes useful for veterinary applications, like Pet and Dog. An ambient module declaration for the vetUtils module would look like:

declare module "vetUtils" {
  export class Pet {
    id: string;
    name: string;
    constructor(id: string, name: string);
  }

  export class Dog extends Pet {
    bark(): void;
  }
}

Assuming the declaration above was in a file vetUtils.d.ts that was included in a project, TypeScript would use the typings in the ambient declaration whenever a module imported resources from “vetUtils”. Note the d.ts extension. This is the extension for a declaration file, which can only contain types, no actual code. Since these files only contain type declarations, TypeScript does not generate compiled code for them.

For ambient declarations to be useful, TypeScript needs to know about them. There are two ways to explicitly let the TS compiler know about declaration files. One is to include declaration files directly in the compilation with the files or include directives in the tsconfig.json file. The other is with a reference triple-slash directive at the top of a source file:

/// <reference types="jquery" />
/// <reference path="../types/vetUtils" />

These comments tell the compiler that a declaration file needs to be loaded. The types form looks for types in packages, similar to how module importing works. The path form gives a path to a declaration file. In both cases, the compiler will identify the directives during preprocessing and add the declaration files to the compilation.

The TS compiler will also look for type declarations in specific locations. By default, it will load ambient types in any package under node_modules/@types. So, for example, if a project includes the @types/node package, the compiler will have type definitions for standard Node modules such as fs and path, as well as for global values like process.

The set of directories TS looks to for types may be configured with the typeRoots compiler option. A similar types option can be used to specify which types of packages are loaded. In both cases, the options will replace the default behavior. If typeRoots is specified, node_modules/@types will not be included unless it’s listed in typeRoots. Similarly if types were set to [“node”], only the node typings would be automatically loaded, even if more types were available in node_modules/@types (or whatever directories were in typeRoots).

Loader plugins

If you’re an AMD user, you’ll probably be used to working with loader plugins (text! and the like). TypeScript doesn’t understand plugin style module identifiers, and although it can emit AMD code with this type of module ID, it can’t load and parse the referenced modules for type checking purposes, at least not without some help. Originally, that meant amd-dependency triple-slash directives:

/// <amd-dependency path="text!foo.html" name="foo" />

declare const foo: string;
console.log(foo);

This directive tells TypeScript that it should add a text!foo.html dependency to the emitted AMD code, and that the name for the loaded dependency should be “foo”.

Since TypeScript 2, though, the preferred way to handle AMD dependencies is with wildcard modules and imports. In a .d.ts file, a wildcard module declaration describes how all imports through the plugin behave. For the text, plugin, an import will result in a string:

declare module "text!*" {
  let text: text;
  export default text;
}

Any files that need to use the plugin can then use standard import statements:

import foo from "text!foo.html";

JSX support

TypeScript started becoming popular not long after React, and it gained support for React’s JSX syntax (including the ability to type check it) in version 1.6. To use JSX syntax in TypeScript, code must be in a file with a .tsx extension, and the jsx compiler option must be enabled.

TypeScript is a compiler, and by default it transforms JSX to standard JS using the React.createElement and React.Fragment APIs. For interoperability in different build scenarios, it can also emit JSX in .jsx files, or JSX in .js files, configurable with the jsx option. The factory and fragment functions can also be changed with the jsxFactory and jsxFragmentFactory options.

Control flow analysis

TypeScript performs control flow analysis to catch common errors and other issues that can lead to maintenance headaches, including (but not limited to):

  • unreachable code
  • unused labels
  • implicit returns
  • case clause fall-throughs
  • strict null checking

While having the compiler catch this type of issue can be very helpful, it can be a problem when adding TS to legacy projects. Many of the issues TS can catch don’t cause code to fail, but can make it harder to understand and maintain, and existing JS code may have many instances of them. Developers may not want to deal with these issues all at once, so the TS compiler allows these checks to be individually disabled with compiler flags such as allowUnreachableCode and noFallthroughCasesInSwitch.

Compiler comments

To make migrating legacy code easier, some special comments can be used to control how TS analyzes specific files or parts of files:

  • // @ts-nocheck – A file with this comment at the top won’t be type checked
  • // @ts-check – When the checkJs compiler option isn’t set, .js files will be processed by the compiler but not type checked. Adding this comment to the top of a .js file will cause it to be type checked.
  • // @ts-ignore – Suppress any type checking errors for the following line of code
  • // @ts-expect-error – Suppress a type checking error for the following line of code. Raise a compilation error if the following line doesn’t having a type checking error.

The @ts-check and @ts-nocheck comments historically only applied to .js files, but as of TS 3.7, @ts-nocheck can also be used for .ts files.

The @ts-expect-error comment is new in TS 3.9. It is useful in situations where a developer needs to intentionally use an invalid type, such as in unit tests. For example, a test that validates some runtime behavior may need to call a function with an invalid value. Using the @ts-expect-error comment, the test can call the function with invalid data without generating a compiler warning, and the compiler will also verify that the function’s input is properly typed.

// src/util.ts

function checkValue(val: string): boolean {
  // ...
}
// tests/unit/util.ts

test('checkName with invalid data', () => {
  // @ts-expect-error
  assert.isFalse(checkValue(5));
});

The @ts-ignore comment could also be used to suppress the error in the example above. However, using @ts-expect-error lets the compiler alert the developer if the argument types to checkValue change. For example, if checkValue was updated to accept string | number, the compiler would emit an error for the test code because checkValue(5) no longer caused the expected type error. That would be actionable information since checkValue(5) was no longer properly testing the invalid data case.

In conclusion

Our Advanced TypeScript post goes into more depth exploring how to use TypeScript’s class system, and explores some of TypeScript’s advanced features, such as symbols and decorators.

As TypeScript continues to evolve, it brings with it not just static typing, but also new features from the current and future ECMAScript specifications. This means you can safely start using TypeScript today without worrying that your code will need to be overhauled in a few months, or that you’ll need to switch to a new compiler to take advantage of the latest and greatest language features. Any breaking changes are described in each version’s release notes and in the TypeScript wiki.

For more detail on any of the features described in this guide, the TypeScript Language Specification is the authoritative resource on the language itself. Stack Overflow is also an excellent place to discuss TypeScript and ask questions, and the official TypeScript Handbook can also provide additional insight above and beyond what this guide provides.

Learning more

With the increased pace of development of JavaScript over the last few years, we believe it’s more important than ever to understand the fundamentals of ES2015+ and TypeScript so that new features can be effectively leveraged in web applications. SitePen is happy to provide you or your company with help developing your next application; just give us a holler to get started!