- Effective TypeScript by Dan Vanderkam (2019)
- Tackling TypeScript by Axel Rauschmayer (2020)
- TypeScript Pro by James Henry
- TypeScript Fundamentals by James Henry
Generics
A generic type is a type that will only be known at call time. A generic is a stand-in for the type that will eventually be used.
We use a generic when…
- we do not want to define the type of the argument that will be passed in, but,
- we do want to use that future type once the argument has been passed in.
The generic captures and represents the type that was provided, so that this stand-in type can be used in a function, class or interface.
function identity<T>(x: T): T {
return x;
}
const num = identity(1);
// `num` is inferred to be type of number
// because the arg 1 is of type number
The point of the identity()
function is to take in and return out the exact same argument, but also to specify that the type of the argument passed in, named T
, will be the type of the return value. In other words, the generic type variable <T>
stands for the type to be passed into, and returned by, the function. Whichever type the argument is, the return type shall be the same. We introduce a generic in a function with the <T>
before the parameter; we use the generic when annotating the function’s argument and return value.
When we call a function that relies on a generic, the typing may be implicit or explicit:
// explicit typing → type set per arg
identity<number>(3);
identity<string>("john");
// implicit typing → type inferred
identity("john");
By convention, the letter T
is used for the first type variable, U
for the second, etc., and K
for each key (property) in an object, but we can call the generic whatever we want.
function identity<T, U>(arg1: T, arg2: U): Array<T, U> {
return [arg1, arg2];
}
Classes can be annotated with generics:
class Container<T> {
data: T;
constructor(data: T) {
this.data = data;
}
useContents(arg: T) {
// ...
}
}
const numberContainer = new Container<number>();
Interfaces can be annotated with generics:
interface Storage<T> {
get(): T;
set(value: T): void;
}
Generic constraints
A generic constraint, with the extends
keyword, restricts the types that a generic accepts. We cna read extends
as “has to be”.
For example, we define an interface Printable
with a print()
method.
interface Printable {
print(): void;
}
And then we declare a function printHousesOrCars()
, which takes in an array of entities whose type is generic but constrained to the Printable
interface. What can pass in “whatever” (generic) but it “has to be” (constraint) a member of some type.
With the constrained generic <T extends Printable>
, we ensure that whichever entity is passed in as an argument must have a type that satisfies the Printable
interface.
function printHousesOrCars<T extends Printable>(arr: T[]) {
arr.forEach((item) => {
item.print();
// `print()` is allowed - all Printables have a `print()` method!
});
}
Conditional typing with generics
We can assign a type conditionally using a ternary expression and the extends
keyword.
type Item<T> = {
container: T extends string ? StringContainer : NumberContainer;
};
The extends
syntax means “is a subset”, that is, if the generic type T
is a subset of string
, then the type for the container
property is StringContainer
. Otherwise, it is NumberContainer
.
Another example:
function process<T extends number | string>(
x: T
): T extends string ? string : number {
// ...
}
If T
is a subset of string
, then the return type is string
. Otherwise, the return type is number
.
Combined with never
, conditional typing with generics can also filter out types:
// constrain the generic to only allow for arrays
type ArrayFilter<T> = T extends any[] ? T : never;
// filter out non-arrays - `StringsOrNumbers` is string[] or number[]
type StringsOrNumbers = ArrayFilter<string | number | string[] | number[]>;
Generic with default type
A generic can default to a type:
class FruitBasket<T = Apple> {
constructor(public fruitsbasket) {}
add(fruit: T) {
this.fruits.push(fruit);
}
eat() {
this.fruits.pop();
}
}
class Fruit {
isFruit: true;
constructor(public name: string) {}
}
class Apple extends Fruit {
type: "Apple";
constructor() {
super("Apple");
}
}
Types as sets
A type can be thought of as a set of values.
- Type
Undefined
→ Set containing one element: the valueundefined
. - Type
Null
→ Set containing one element: the valuenull
. - Type
Boolean
→ Set containing two elements: the valuestrue
andfalse
. - Type
Number
→ Set containing all numbers, such as1
,2
, etc. - Type
String
→ Set containing all strings, such as"hi"
,"bye"
, etc. - Type
Object
→ Set containing all objects (including functions and arrays).
A set may contain all elements, a finite or an infinite number of elements, or no elements:
Set | Example |
---|---|
Set containing all elements (universal set) | unknown |
Set containing an infinite number of elements | string , number |
Set containing an finite number of elements | string literal |
Set containing no elements (empty set) | never |
TypeScript terms can be thought of as having equivalents in set theory:
TypeScript term | Equivalent in set theory |
---|---|
a value is assignable to a type | an element is a member of a set |
a type is assignable to another type | a set is a subset of another set |
an interface extends another interface |
a set is a subset of another set |
type | type |
a union of two sets |
type & type |
an intersection of two sets |
In the example below, value "A"
is assignable to type AB
, i.e. value "A"
is a member of set {"A", "B"}
. By contrast, value "C"
is not assignable to type AB
, i.e. value "C"
is not a member of set {"A", "B"}
.
type AB = "A" | "B"; // set
const a: AB = "A"; // element in set
This means that, if a variable has a type, the values that can be assigned to that variable are elements in the set of that type. Out of all possible values, the values that are assignable to a variable have to be elements in the set of that type.
extends
as a subset indicator
In interfaces, the keyword extends
indicates that the extending interface is a subset of the original interface, i.e. it has all its typed properties and more.
// larger set
interface Person {
name: string;
}
// smaller set
interface Employee extends Person {
role: string;
joined: Date;
}
In generics, extends
constrains the generic type, which means that the extending set is a subset of the set represented by the generic type. The generic type is the universal set, a set containing every possible value, and extends
reduces that universal set to a subset. In other words, extending a generic type with string
means that only a value that is a member of string
can be used as that generic type. Below, the value "a"
is assignable to type string
, which extends
(i.e. is a subset of) the generic type K
.
function getKey<K extends string>(value: any, key: K) {
// string is a subset of K → "a" is assignable to string and therefore to K
}
Excess property checking
An interface can be interpreted in two different ways:
- The interface describes the maximum shape of the object, so excess properties are disallowed.
- The interface describes the minimum shape of the object, so excess properties are allowed.
Therefore, TypeScript…
- disallows excess properties when the object is created and typed at the same time, but
- allows excess properties is the object is typed after it was created.
Excess property checking disallows unknown properties on an object literal when assigned to a typed variable. That is, when you create an object and assign it on the spot to a variable having a declared type, TypeScript checks it has the properties of that type and no excess properties.
interface Room {
numDoors: number;
ceilingHeightFt: number;
}
// assigning object literal to typed variable
const room: Room = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: "present",
};
// Error: Object literal may only specify known properties,
// and "elephant" does not exist in type "Room"
To allow for excess properties in an object, type the object after it was created.
interface Room {
numDoors: number;
ceilingHeightFt: number;
}
// assigning object literal to untyped variable
const obj = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: "present",
};
// typing after the fact
const r: Room = obj;
Or instruct TypeScript to expect excess properties using an index signature:
interface Room {
numDoors: number;
ceilingHeightFt: number;
[otherOptions: string]: unknown;
}
Imports and exports
Namespace import
To import a module into a single variable, use import * as
.
// module to be imported
export const CLIENT_ID = 8034;
export const CLIENT_NAME = "John Smith";
import * as client from "./client";
console.log(client.CLIENT_ID);
console.log(client.CLIENT_NAME);
Non-standard import
If you have a CommonJS module whose single export is a non-namespaced value, use the non-standard import.
To import a module that uses export = fn
or module.exports = fn
, use import module = require("module")
.
// module to be imported
let numberRegexp = /^[0-9]+$/;
class ZipCodeValidator {
isAcceptable(s: string) {
return s.length === 5 && numberRegexp.test(s);
}
}
export = ZipCodeValidator;
import zip = require("./ZipCodeValidator");
let strings = ["Hello", "98052", "101"];
let validator = new zip();
strings.forEach((s) => {
console.log(
`"${s}" - ${validator.isAcceptable(s) ? "matches" : "does not match"}`
);
});
This non-standard import is unneeded if the module to be imported uses export = { fn }
or module.exports = { fn }
.
Module scoping
If we do not declare any imports or exports in a file, TypeScript infers that the file is globally scoped. It does not need to be explicitly imported into another file to be used there.
The presence of a single import
or export
in a file changes how TypeScript deems it locally scoped or file-scoped.
Type guards
A type guard is a condition that narrows down a union type, which allows for better TypeScript analysis. For a union type, TypeScript offers only the properties and methods that in common between the types. To restore access to properties and methods that are not in common between the types, use a type guard to check for the type.
For a primitive value, use an if
statement with the typeof
operator.
if (typeof value === "string") {
// `value` is type string
}
If value
is a string | number
union type, adding this type guard ensures that, inside the if
block, TypeScript knows that value
is type string
and offers only string properties and methods.
For an object type, use an if
statement with the instanceof
operator.
if (collection instanceof Array) {
// `collection` is type Array
}
If collection
is a number[] | string
union type, adding this type guard ensures that, inside the if
block, TypeScript knows that collection
is type Array
and offers only array properties and methods.
User-defined type guard
A user-defined type guard is a custom function that determines if a type if an argument is a type. It is a function that returns a boolean and where the return type after the colon is specified as argument is type
. As with a standard type guard, a user-defined type guard is intended to narrow down a type for better TypeScript analysis.
/**Determines if the argument is an HTMLInputElement
based on whether it has a `"value"` property.*/
function isInputElement(elem: HTMLElement): elem is HTMLInputElement {
return "value" in elem;
}
function getElementContent(elem: HTMLElement) {
if (isInputElement(elem)) {
// `el` is type HTMLInputElement
return elem.value;
}
// `elem` is type HTMLElement
return elem.textContent;
}
User-defined type guards, in turn, can be used filter out values that are not null when constructing a type:
function isNonNullable<T>(x: T | undefined | null): x is T {
return x !== undefined && x !== null;
}
Mapped types
A mapped type is a type based on another type, through iteration over its constituents.
type Options = "option1" | "option2";
// mapped type
type Flags = { [O in Options]: boolean };
// spelled-out equivalent of mapped type
type Flags = {
option1: boolean;
option2: boolean;
};
A mapped type can also be based on the keys of its base type.
type Employee = {
name: string;
age: number;
};
// type based on `Employee` where every property is optional
type PartialEmployee = {
[P in keyof Employee]?: Employee[P];
};
// type based on `Employee` where every property is read-only
type ReadonlyEmployee = {
readonly [P in keyof Employee]: Employee[P];
};
// type based on `Employee` where every property can be null
type NullableEmployee = {
[P in keyof Employee]: Employee[P] | null;
};
A mapped type can be generalized with generics:
type Partial<T> = {
[K in keyof T]?: T[K];
};
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
Incidentally, many mapped types with generics are already built-in as utility types.
type PartialEmployee = Partial<Employee>;
type ReadonlyEmployee = Readonly<Employee>;
type NullableEmployee = Nullable<Employee>;
Record<K, T>
constructs a type out of different sets of keys and values:
// type to be used for keys
type Employees = "john" | "james" | "william";
// type to be used for values
interface EmployeeData {
position: string;
salary: number;
}
type EmployeeLedger = Record<Employees, EmployeeData>;
const employeeLedger: EmployeeLedger = {
john: { position: "manager", salary: 80000 },
james: { position: "developer", salary: 60000 },
william: { title: "intern", salary: 40000 },
};
Pick<T, K>
constructs a type by picking out specific keys from a type.
interface EmployeeData {
firstName: string; // to be picked for bio
lastName: string; // to be picked for bio
dateOfBirth: Date; // to be picked for bio
position: string;
salary: number;
}
type EmployeeBio = Pick<EmployeeData, "firstName" | "lastName" | "dateOfBirth">;
const johnBio: EmployeeBio = {
firstName: "john",
lastName: "smith",
dateOfBirth: "10/10/1980",
};
Lookup types
The keyof
operator grabs the keys of an interface:
interface Person {
name: string;
age: number;
location: string;
}
type PersonKeys = keyof Person;
// spelled-out equivalent of lookup type with `keyof`
type PersonKeys = "name" | "age" | "location";
The extends
keyword can be combined with the keyof
operator:
// the second generic has to be a key of the first generic!
function getObjectProperty<T, K extends keyof T>(object: T, key: T) {
return object[key];
}
const fruit = {
name: "Apple",
sweetness: 80,
};
getObjectProperty(fruit, "sweetness"); // → 80
Another lookup type is property access in a type, just like property access in an object, using square brackets:
type personName = Person["name"];
// spelled-out equivalent of lookup type with property access
type personName = string;
Index signature
We use an index signature to express the types of all the keys and values in an interface:
interface TranslationDictionary {
[key: string]: string;
}
In TranslationDictionary
, every key and every value is of type string
.
const dictionary: TranslationDictionary = {
Yes: "Sí",
No: "No",
Maybe: "Quizás",
};
We can also change the index signature to apply a change to every key or value. For example, readonly
in an index signature prevents all keys from being reassigned.
interface Person {
readonly [key: string]: string;
}
let person: Person = {
name: "john",
};
person.name = "william"; // disallowed
Function overloads
Function overloads express the different type pairings of inputs and outputs to a function. In other words, a function may return different types based on the arguments passed in—and this relationship can be expressed with function overloads.
type Falsy = false | 0 | "" | null | undefined;
type Truthy<T> = T extends Falsy ? never : T;
/**
When the argument is falsy, foo returns {}
When the argument is truthy, foo returns { x: T }
When the argument is unknown, foo may return {} or { x: T }
*/
function foo(value: Falsy): {};
function foo<T>(value: Truthy<T>): { x: T };
function foo(value: any) {
return !value ? {} : { x: value };
}
const res1 = foo(""); // inferred: `{}`
const res2 = foo(1); // inferred: `{x: number}`
const res3 = foo("a"); // inferred: `{x: string}`
Discriminating union
A discriminating union is a union of types or interfaces having at least one shared property with a unique type.
interface Fruit {
type: "fruit"; // shared property with unique type
name: string;
color: string;
getJuice: () => void;
}
interface Vegetable {
type: "vegetable"; // shared property with unique type
name: string;
color: string;
steam: () => void;
}
type Edible = Fruit | Vegetable;
function cook(ingredient: Edible) {
if (food.type === "fruit") {
// typechecker has narrowed `ingredient` down to `Fruit`
food.juice();
}
}
Odd operators
Non-null assertion operator
The non-null assertion operator !
asserts that a type is not null
and not undefined
. Since this is an assertion, we are overriding the typechecker.
let s = e!.name; // assert that e is not null and access name property
Nullish coalescing operator
The nullish coalescing operator ??
allows us to set a fallback value if a primary value is null
or undefined
.
result = myVar ?? "Fallback";
// if `myVar` is not null and not undefined, result is set to `myVar´
// if `myVar` is null or undefined, result is set to `"Fallback"´
Note that the ||
operator falls back to the right value if left is falsy (which includes false
, 0
, ""
, NaN
etc.), whereas the ??
uses the right value if left is null
or undefined
.
Optional chaining operator
The chaining operator .
allows us to access properties and methods in an object. The optional chaining operator ?.
allows us to access properties and methods in an object without having to validate that each reference in the chain.
If the reference is valid, the property or method is accessed; if the reference is invalid, the access attempt returns undefined
.
// valid reference
const result = {
person: {
getName() {
return "john";
},
},
};
const name = result.person?.getName(); // `name` is set to `"john"`
// equivalent to...
let john = "";
if (result && result.person && result.person.getName) {
john = result.person.getName();
}
// invalid reference
const name = result.person?.getFullName(); // `name` is set to `undefined`
Enums
Use enums for sets of constants that belong together:
enum Reply {
Yes = "Yes",
No = "No",
}
enum LogLevel {
off = "off",
info = "info",
warn = "warn",
error = "error",
}
Parameter modifiers
TypeScript offers optional parameters, default parameters and rest parameters.
Optional parameter
In JavaScript, every parameter is optional (i.e. omittable). When omitted, a parameter’s value is undefined
.
In TypeScript, every parameter is required unless made optional (i.e. omittable) with a ?
. Inside the function, an optional parameter will have the union type T | undefined
and will be undefined
when omitted.
function buildName(firstName: string, lastName?: string) {
if (lastName) {
return `${firstName} ${lastName}`;
} else {
return firstName;
}
}
buildName("john"); // success
buildName("john", "smith"); // success
Compare this with explicitly typing a parameter with the union type T | undefined
, in which case the parameter cannot be omitted—the parameter is not optional and therefore has to be passed in.
function buildName(firstName: string, lastName?: string) {
if (lastName) {
return `${firstName} ${lastName}`;
} else {
return firstName;
}
}
buildName("john"); // error
buildName("john", "smith"); // success
buildName("john", undefined); // success
Default parameter
We can set a default value (i.e. fallback) for a parameter:
function buildName(firstName: string, lastName: string = "doe") {
return firstName + " " + lastName;
}
buildName("john", "smith"); // → "john smith"
buildName("john"); // → "john doe"
Rest parameter
We can gather params into a single variable using ...
as a prefix:
function buildName(firstName: string, ...restOfName: string[]) {
// `firstName` points to "john"
// `secondName` points to ["alexander", "smith"]
return firstName + " " + restOfName.join(" ");
}
buildName("john", "alexander", "smith"); // → "john alexander smith"
The rest parameter might have also been called the “collect” parameter. The compiler will build an array of the arguments passed in with the name given after the ellipsis ...
, allowing you to use it in your function.
tsconfig.json
The tsconfig.json
file specifies the source files and compiler options for TS-to-JS transpilation.
Official docs on the
tsconfig.json
file
Source files
With the files
property, source files can be defined one by one:
{
"compilerOptions": {
// ...
},
"files": ["file1.ts", "file2.ts"]
}
With the include
and exclude
properties, source files can be defined as a glob-like pattern:
{
"compilerOptions": {
// ...
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
The exclude
property filters out the files in the include
property.
The supported glob wildcards are:
*
matches zero or more characters (excluding directory separators)?
matches any one character (excluding directory separators)**/
recursively matches any subdirectory
The files
property and the include
/exclude
pair can be combined. If so, files
takes priority over exclude
.
Compiler options
Strict type checking
Enable strict type checking with strict
, which enables:
noImplicitAny
: If a type is not inferrable, it must be specified.noImplicitThis
: If the type ofthis
cannot be inferred, it must be specified.alwaysStrict
: Use JavaScript’s strict mode directive.strictNullChecks
:null
andundefined
are standalone types and not part of others exceptany
.strictBindCallApply
strictFunctionTypes
strictPropertyInitialization
: Properties (i.e. fields) in classes must be initialized.
For better type safety, do not to enable options starting with suppress-
, such as suppressImplicitAnyIndexErrors
and suppressExcessPropertyErrors
.
Configuring the output
{
"compilerOptions": {
"module": "...", // output module framework
"target": "..." // output JS version (syntax)
}
}
Specifying directories
{
"compilerOptions": {
"rootDir": "...", // source directory for output tree
"outDir": "..." // output directory
}
}
Adjusting the transpilation process
{
"compilerOptions": {
"typeRoots": "...", // only include the type defs in these dirs
"types": "..." // only include the type defs in these @types packages
}
}
Working with TypeScript
Dependency management
The first dependency to consider is TypeScript itself. It is possible to install TypeScript system-wide, but this is generally a bad idea for two reasons:
- There is no guarantee that you and your coworkers will always have the same version installed.
- It adds a step to your project setup.
Make TypeScript a devDependency instead.
Put @types
dependencies in devDependencies
, not dependencies. If you need @types
at runtime, then you may want to rework your process.
Testing
Install Jest, TS Jest and type declarations as dev dependencies:
npm i -D jest ts-jest @types/jest
Configure Jest to find tests in TypeScript, transpile them and run them:
{
"jest": {
"moduleFileExtensions": ["ts", "js"],
"transform": {
".ts": "<rootDir>/node_modules/ts-jest/preprocessor.js"
},
"testRegex": ".*\\.spec\\.ts$"
}
}
Or…
module.exports = {
testMatch: ["<rootDir>/tests/*.test.ts"],
transform: {
"^.+\\.ts$": "ts-jest",
},
};
Add test commands:
{
"scripts": {
"test": "jest",
"test:watch": "npm test -- --watch"
}
}
Write a test:
test("it should work", () => {
expect(true).toEqual(true);
});
Install Git hooks for pre-commit testing:
npm i -D husky
And add a pre-commit command:
{
"scripts": {
"precommit": "npm test"
}
}
Source maps
Source maps map positions and symbols in a generated file back to the corresponding positions and symbols in the original source.
To tell TypeScript to generate one, set the sourceMap
option in tsconfig.json
:
{
"compilerOptions": {
"sourceMap": true
}
}
Now when you run tsc
, it generates two output files for each .ts file: a .js file and a .js.map file.
Type coverage
Because of the negative effects any types can have on type safety and developer experience, it ois a good idea to keep track of the number of them in your codebase. There are many ways to do this, including the type-coverage
package.
npx type-coverage
9985 / 10117 98.69%