TypeScript: The Good Parts
By Mark Volkmann, OCI Partner and Principal Software Engineer
November 2019
In his book, JavaScript: The Good Parts, Douglas Crockford states, "In JavaScript, there is a beautiful, elegant, highly expressive language that is buried under a steaming pile of good intentions and blunders."
The combination of features added to JavaScript in ES2015 (ES6) and beyond, plus the use of ESLint, makes JavaScript quite a nice language.
TypeScript is a superset of the JavaScript language. Therefore, it contains the same beautiful features and blunders that are present in JavaScript. But it also adds many features, some of which are beautiful and some not so much.
This article presents the most valuable parts of the TypeScript language. At the end, it lists features that most developers should avoid.
Overview
TypeScript is an open source programming language developed and maintained by Microsoft (https://www.typescriptlang.org/). It adds types and more to JavaScript. Types can be added gradually. Specifying more types allows TypeScript to find more errors.
TypeScript source files have a file extension of .ts
(or .tsx
if they contain JSX). These files are compiled to .js
files.
The TypeScript compiler requires Node.js to be installed in order to run.
Assumptions
This article assumes knowledge of JavaScript and focuses on the features that TypeScript adds.
Experience with a language that has classes and interfaces like C# or Java is helpful.
The example code here assumes that the most strict TypeScript settings possible are in effect. Otherwise some errors described here will not be reported.
Benefits
TypeScript provides many benefits:
- Catches errors at compile-time
- Provides documentation
- Allows editors to flag errors while typing and provides completion
- Makes refactoring less error-prone
- Makes some tests unnecessary
- Disallows many JavaScript type coercions
One of the criticisms of JavaScript is that many seemingly invalid expressions actually produce a result, rather than triggering an error at runtime. For a humorous summary of some of these, see Gary Bernhardt's "WAT" talk.
TypeScript addresses most of these issues by disallowing many type coercions that JavaScript allows. For example, TypeScript allows adding a number and a string (resulting in concatenation), but it does not allow adding an object and a number.
Creating a TypeScript Project
Let's get started by creating a TypeScript project. Here are the steps:
- Install Node.js from https://nodejs.org.
- Create a directory for the project and
cd
to it. - Create a
package.json
file by runningnpm init
. - Locally install the TypeScript compiler (and optionally the type definitions for Node) by running
npm install -D typescript @types/node
. - Create a
tsconfig.json
file by runningnpx tsc --init
.
The file created will contain many commented-out options.npx
searches in./node_modules/.bin
for executables installed bynpm
.tsc
is an abbreviation for "TypeScript Compiler." - Modify
tsconfig.json
to match the starting point described in the next section. - Create a
src
directory at the top of your project directory. - When you are ready to create new
.ts
files, place them in and under thesrc
directory. - Add the following npm script in
package.json
:"compile": "tsc",
- Compile all your
.ts
files to.js
files by runningnpm run compile
.
This compiles all the.ts
files under thesrc
directory to.js
files under thedist
directory. - Run the main
.js
file by enteringnode dist/index.js
.
This assumes the main.ts
file issrc/index.ts
.
To see the version of TypeScript that is installed, enter npx tsc -v
.
TypeScript Configuration
The file tsconfig.json
contains configuration options for the TypeScript compiler. It is used only when tsc
is run with no specified input files. If you run tsc
with no specified input files, it compiles all TypeScript files in the project.
Some top-level properties are compilerOptions
and include
. Others include compileOnSave
, exclude
, extends
, and files
. A good starting point for this file is:
{
"compilerOptions": {
"esModuleInterop": true,
"module": "commonjs",
"noImplicitReturns": true,
"outDir": "dist",
"strict": true,
"target": "es5",
}
"include": ["src"]
}
The option compilerOptions
is an object with many properties including:
esModuleInterop
This allows the use of ES5 default exports and imports.jsx
Ignore this option when not using JSX. Valid values are"preserve"
,"react"
, and"react-native"
.lib
This is an array of APIs assumed to exist.
Example:["dom", "es2015"]
module
This specifies the module system to use.
Example: Use"es2015"
for modern browsers or"commonjs"
for Node.js.noImplicitReturns
Set this totrue
to require functions that return a value to do so from all code paths.outDir
This is the directory where.js
files should be written.
Example:"dist"
.sourceMap
Set this totrue
to generate source map files. Generating source maps requires installing the npm packagesource-map-support
.strict
Set this totrue
to require all code to be typed.target
This specifies the JavaScript version to generate.
Example:"es5"
for older browsers or"es2015"
for modern browsers and Node.js.
The option include
is an array of directories where .ts
files are found.
Example, "include": ["src"]
.
Strict Mode
Setting the compilerOption
strict
to true
implies many other options listed below.
alwaysStrict
This parses in strict mode and emits"use strict"
at the top of each generated.js
file.noImplicitAny
This raises an error on declarations and expressions with an inferred type ofany
.noImplicitThis
This raises an error onthis
expressions with an implied type ofany
.strictBindCallApply
This enables stricter checking of thebind
,call
, andapply
methods on functions.strictFunctionTypes
This checks function type parameters covariantly instead of bivariantly. Suffice it to say, you want this. The curious can learn more about these terms from Ben Weissmann's Strange Loop talk and https://codewithstyle.info/Strict-function-types-in-TypeScript-covariance-contravariance-and-bivariance/.strictNullChecks
This makesnull
andundefined
values disallowed for every type by default. With this option,null
andundefined
are only assignable to themselves and theany
type (exceptundefined
is assignable to thevoid
type).strictPropertyInitialization
This ensures that class properties with a type that does not allowundefined
are initialized in the constructor. It also requires thestrictNullChecks
option.
TypeScript Flow
At compile-time, tsc
- reads
.ts
files - creates an abstract syntax tree (AST)
- performs type checking against the AST
- generates one
.js
file for each.ts
file
At runtime, a JavaScript Engine
- reads
.js
files - generates a different AST
- generates bytecode from AST
- evaluates the bytecode
Since the JavaScript engine steps are not visible, it feels like JavaScript is an interpreted language.
TypeScript Node
Another way to compile and run TypeScript code in a Node environment is to use ts-node
at https://github.com/TypeStrong/ts-node.
To install this, enter npm i -g typescript ts-node
.
To compile and run a TypeScript source file, enter ts-node name.ts
.
Editor Support
The VS Code editor has great TypeScript support. To enable it, open Settings and check "Typescript > Validate". Once this is enabled, errors will be detected as you type. Hover over a variable to see its type.
VS Code doesn't currently honor the compileOnSave
setting in tsconfig.json
. See https://github.com/microsoft/vscode/issues/973.
As a workaround, enter tsc --watch
in a terminal window to watch for file changes and automatically compile.
Other editors with TypeScript support include Atom, Eclipse, Emacs, Sublime, Vim, Visual Studio, and WebStorm.
Linting
There are two popular options for linting TypeScript code: TSLint and ESLint. TSLint will soon be deprecated in favor of ESLint, but currently TSLint supports rules not yet implemented for ESLint.
TSLint
To install TSLint, enter npm install -D tslint
.
To generate a default tslint.json
file that configures the use of TSLint, enter npx tslint --init
. Edit this file to customize the linting rules.
To run TSLint on all source files in the current project, enter npx tslint --project .
.
To run TSLint using an npm script, add the following line to the scripts
section of package.json
:
"lint": "tslint --project ."
With this in place, TSLint can be run by entering npm run lint
.
ESLint
To install ESLint, enter npm install -D *
, where *
is the following:
eslint
@typescript-eslint/parser
@typescript-eslint/eslint-plugin
eslint-config-prettier
eslint-plugin-prettier
eslint-plugin-react
(only when using React)
Configure ESLint by creating the file .eslintrc.json
. A good starting configuration is the following. Options that include the words "react" and "jsx" can be omitted when not using React. The "rules" option is used to override rules from the "extends" rule sets.
{
"extends": [
"plugin:@typescript-eslint/recommended",
"prettier/@typescript-eslint",
"plugin:prettier/recommended",
"plugin:react/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"rules": {
},
"settings": {
"react": {
"version": "detect"
}
}
}
To run ESLint using an npm script, add the following line to the scripts
section of package.json
:
"lint": "eslint --cache --ext=.ts,.tsx --fix src"
With this in place, ESLint can be run by entering npm run lint
.
Formatting
The best code formatter for TypeScript is Prettier at https://prettier.io/.
To install Prettier, enter npm install -D prettier
.
To run Prettier using an npm script, add the following line to the scripts
section of package.json
:
"format": "prettier --write src/**/*.{ts,tsx}"
With this in place, Prettier can be run by entering npm run format
.
Types
Types define allowed values and operations that can be performed on values. For example, the number type allows only integer and floating point values. One operation that can be performed on numbers is multiplication.
Type Hierarchy
The diagram below shows the relationships between the built-in types provided by TypeScript.
Built-in Types
The primitive types supplied by TypeScript include:
-
boolean
Values aretrue
andfalse
-
number
Integers (up to 2^53) and floats -
string
Text -
bigint
Holds any size integer
As of 8/30/19 this type is not supported by IE11 or Safari. -
symbol
Holds immutable, unique values
These are sometimes used as keys in objects and Maps. -
null
Typically represents currently having no value -
undefined
Typically represents having never had a value
Other built-in types include:
-
any
Default type; allows anything
It's best to avoid using this. -
unknown
Likeany
, but requires "refinement" when values are used
Refinement involves checking the type of a variable in code, typically using anif
statement. -
undefined
andnull
These types are used only in union types, not on their own.
For example:
let name: string;
//console.log(name); // used before being assigned
name = 'Tami';
console.log(name); // Tami
//name = null; // not assignable
let name2: string | null | undefined;
console.log(name2); // undefined
name2 = 'Mark';
console.log(name2); // Mark
name2 = null;
console.log(name2); // null
void
This type is used only as the return type of functions that return nothing.
function printSum(n1: number, n2: number): void {
console.log(n1 + n2);
}
-
never
This type represents something that never happens. One use is as the return type of functions that never return. It is difficult to think of a useful example. You may never usenever
.
Non-Primitive Types
-
objects
One form of these is described in the next section. -
arrays
These are described later. -
any JavaScript class
Examples includeDate
,Error
,Function
,Map
,Promise
,RegExp
, andSet
.
Objects With Unspecified Properties
There are several ways to declare objects that can hold arbitrary properties. These are rarely used. It is more common to specify the allowed properties using interfaces or type aliases which are described later.
object
Variables of this type can be assigned any object or array.Object
or{}
Variables of these types can be assigned any object, array, or primitive value. Because primitive values can be assigned, these types should be avoided.{[key: string]: valueType}
This defines an object type where the keys can be any string value and the values can be of the type specified byvalueType
.Record<keyType, valueType>
This is similar to the previous option. For details, see the RECORD UTILITY TYPE section later.
Note that undefined
and null
cannot be assigned to variables of any of these types.
Union Types
Union types define a new type that matches any of a list of specified types.
Assuming the classes Dog
and Cat
are defined, we can define a Pet
type as follows:
type Pet = Cat | Dog;
Here is another example:
type Custom = number | string | undefined;
// If undefined was not an allowed type,
// this would not be assignable.
//let c: Custom = undefined;
let c: Custom;
console.log(c); // undefined
c = 1;
console.log(c); // 1
c = 'x';
console.log(c); // x
//c = true; // not assignable
//c = null; // not assignable
Enums
Enum types define a list of allowed number
or string
values. They are like objects where keys are strings and values are numbers or strings.
By default, values are integers starting from zero. Specific number
or string
values can be assigned. It is even possible to mix number
and string
values in the same enum
, but doing this is not typical.
When the values are numbers, their keys can be retrieved by value. This cannot be done when the values are strings.
Here are some examples:
// number enum
enum Color {
Red, // 0
Green = 10, // 10
Blue // 11; next value after Green
}
let c: Color = Color.Blue;
console.log(c); // 11
c = 10; // can set number value
console.log(c); // 10
c = 30; // can set number value not present
console.log(c); // 30, not an error
//c = Color.Yellow; // error, does not exist
console.log(Color[10]); // Green
console.log(Color[12]); // undefined, not error
// string enum
enum HexColor {
Red = 'F00',
Green = '0F0',
Blue = '00F'
}
let hc: HexColor = HexColor.Blue;
console.log(hc); // 00F
//hc = 1; // error, not assignable
hc = HexColor.Red;
console.log(hc); // F00
//hc = HexColor['0F0']; // error, property does not exist
When an enum
type is declared as const
, values can be accessed by key, but keys cannot be accessed by value. A benefit of using const
enums is that the TypeScript compiler can inline member references.
const enum ConstColor {
Red,
Green,
Blue
}
const cc: ConstColor = ConstColor.Blue;
console.log(cc); // 2
//console.log(ConstColor[1]); // can only access by key
console.log(ConstColor.Green); // 1
console.log(ConstColor['Green']); // 1
Many developers prefer using union types instead of enums.
For example:
// Instead of this enum ...
const enum Color {
Red,
Green,
Blue
}
// can use this union type.
type Color = 'red' | 'green' | 'blue';
let c: Color = 'red';
c = 'pink'; // "pink" is not assignable
One reason for this is that enums with number values allow any number to be assigned.
For example:
const enum Color {
Red,
Green,
Blue
}
let c: Color = Color.Red; // valid value
c = 19; // not a valid value, but allowed
Arrays
Array types have the syntax type[]
or Array
. Their type can also be inferred from an initial value.
For example:
const things = [99, 'Gretzky']; // type is (string | number)[]
While an array type can allow elements with different types, it is best to use a common type for all elements so TypeScript can better enforce their usage.
The type of an untyped array is narrowed when it leaves the scope in which it was created.
For example:
function getThings() {
const arr = []; // type is any[]
arr.push(1);
arr.push('x');
return arr; // type is (string | number)[]
}
const things = getThings(); // type is (string | number)[]
things.push(new Date()); // error: not a string or number
Tuples
Tuples are a subtype of arrays. They have a fixed length and a specific type at each index.
For example:
type Point = [number, number];
function translate(point: Point, dx: number, dy: number): Point {
const [x, y] = point; // destructuring
return [x + dx, y + dy];
}
const p1: Point = [1, 2];
const p2 = translate(p1, 2, 3);
console.log(p2); // [3, 5]
The element at each index can hold a different type (heterogeneous). Particular elements can be made optional by adding ?
after their type.
For example:
type PlayerScore = [string, number?];
It is often preferred to use an interface instead of a tuple, so that each piece of data has a name.
For example:
// Using a tuple
type PlayerScore = [string, number];
const ps: PlayerScore = ['Mark', 19];
// Using an interface is usually better.
interface Player {
name: string;
score: number;
}
const p: Player = {name: 'Mark', score: 19};
readonly
Modifier
The readonly
modifier can be applied to array types, tuple types, and object properties inside interfaces or type aliases. This prevents their values from being modified, making them immutable.
For example:
type Numbers = readonly number[]; // primitive array
const n: Numbers = [1, 2, 3];
//n[1] = 7; // only permits reading
//n.push(4); // property "push" does not exist on readonly arrays
type Dates = readonly Date[]; // object array
const d: Dates = [new Date()];
//d[0] = new Date(); // only permits reading
type PlayerScore = readonly [string, number, Date]; // tuple
const ps: PlayerScore = ['Mark', 19, new Date()];
//ps[1] = 20; // cannot assign to read-only element
type Lines = string[];
// Can't apply readonly to a type alias,
// only to array and tuple literal types.
//const lines: readonly Lines = ['one', 'two'];
// Can apply to individual properties of object types.
interface ImmutablePoint {
readonly x: number;
readonly y: number;
readonly d: Date;
}
const p1: ImmutablePoint = {x: 1, y: 2, d: new Date()};
//p1.x = 3; // cannot assign to read-only property
//p1.d = new Date(); // cannot assign to read-only property
Generic Types
Generic types can be used in functions, type aliases, classes, interfaces, and methods.
Type parameters are names used for parameter types, return types, and variable types. They are specified in angle brackets (e.g., <T, U>
). There can be any number of type parameters, and they can be given any names, but common names are T
, U
, and V
. It is rare to need more than three.
All occurrences of a given type parameter are replaced with the same type at runtime. TypeScript can infer type parameter types at runtime, but they can also be made explicit. We will see many examples of this later.
Readonly
Utility Type
Readonly
is one of the provided utility types. More are described later.
Readonly
creates a new type from an existing one where all object properties are readonly.
For example:
interface MutablePoint {
x: number;
y: number;
d: Date;
}
const p2: Readonly<MutablePoint> = {x: 1, y: 2, d: new Date()};
//p2.x = 3; // cannot assign to read-only property
//p2.d = new Date(); // cannot assign to read-only property
Declaring Variables
TypeScript will infer variable types when initial values are assigned.
For example:
let score = 0; // infers number
const teams = 2; // infers 2, not number, because it’s immutable
Variable types can also be specified explicitly.
For example:
let score: number; // defaults to 0
const teams: number = 2; // no need to specify type here
Type Assertions
There are two equivalent ways to assert that the TypeScript compiler should treat a value as a specific type. Here are examples of both approaches where we want the treat the value of a variable score
as a number
:
variable as number
<number>variable
Type Aliases
Type aliases can give an alternate name to another type.
For example:
type Age = number;
const myAge: Age = 29;
type Pet = Cat | Dog;
Type aliases can also define a type that allows a fixed set of values.
For example:
type Color = 'red' | 'green' | 'blue';
const color: Color = 'blue';
Another use for type aliases is to define an object "shape."
For example:
type Address = {
street: string;
city: string;
state: string;
zip: number;
};
const myAddress: Address = {
street: '123 Some St.',
city: 'Somewhere',
state: 'Missouri',
zip: 12345
};
While this works, most TypeScript developers prefer using interfaces instead of type aliases to define object shapes.
Interfaces are described later.
Type aliases are block-scoped like const
and let
.
More on Shapes
Object properties described by interfaces and type aliases are required by default. Also by default, additional properties cannot be added to objects of these types.
To make a property optional, add ?
after its name.
To make a property read-only, add readonly
before its name.
To allow arbitrary additional properties, add an index signature.
For example:
[key: keyType]: valueType
key
can have a different name, but using key
is common.
keyType
must be string
or number
. valueType
can be any type, including any
.
For example:
type Address = {
street: string;
city?: string;
state?: string;
readonly zip: number;
[key: string]: any;
};
Generic Type Aliases
Type aliases can use generic types.
For example:
type Range<T> = {
min: T;
max: T;
};
const numberRange: Range<number> = {min: 1, max: 10};
const letterRange: Range<string> = {min: 'a', max: 'f'};
const teamRange: Range<Team> = {
min: new Team('Cubs'),
max: new Team('Cardinals')
};
Function Parameter and Return Types
Function parameter types and return types can be specified regardless of how the function is defined. This includes named functions, arrow functions, and function expressions.
For example:
// Named function
// Same as the JavaScript String repeat method.
function stringRepeat(text: string, repeat: number): string {
let result = '';
for (let i = 0; i < repeat; i++) {
result += text;
}
return result;
}
// Example call
const santaSays = stringRepeat('Ho ', 3); // 'Ho Ho Ho '
// Arrow function
const stringRepeat = (text: string, repeat: number): string => {
// same code as above
};
// Function expression
// Typically the other forms are preferred over this.
const stringRepeat = function(text: string, repeat: number): string {
// same code as above
};
Function Signature Types
Function signature types define the parameter types and return types of functions that are defined elsewhere.
For example:
type StrNumFn = (s: string, n: number) => string;
Parameter names are just for documentation. Implementations can use other names.
Parameter default values cannot be specified in function signatures, but they can be specified in function definitions.
A return type must be specified, unlike in function definitions where it can be inferred.
Function signature types can be used as the type of functions when they are defined. They can also be used as parameter types when functions can be passed as an argument to other functions.
Parameter types are not inferred unless the function itself is typed using a function signature type. However, in functions that are not typed using a function signature type, the return type can be inferred by return statements in the body if the type of each return statement can be inferred.
For example:
// Based on the use of the StrNumFn type,
// the text parameter type is inferred to be string.
// The repeat parameter type is inferred to be number.
// We could specify parameter default values.
// The return type is inferred to be string.
const stringRepeat: StrNumFn = (text, repeat) => {
let result = '';
for (let i = 0; i < repeat; i++) {
result += text;
}
return result;
};
Function signature types are useful in functions that take other functions as arguments.
For example:
function processStr3(text: string, fn: StrNumFn): string {
return text.length > 0 ? fn(text, 3) : 'empty';
}
Optional, Default, and Variadic Parameters
To make a parameter optional, add ?
after its name. This is only allowed for ending parameters.
For example:
function stringRepeat(text: string, repeat?: number): string { ... };
To give a parameter a default value, add = value
. The parameter type can be inferred from the default value.
Specifying a default value for a parameter is more common than making it optional.
For example:
function stringRepeat(text: string, repeat = 1): string { ... };
Variadic functions, which are functions that take a variable number of arguments, are defined using a rest parameter at the end, just like in JavaScript.
For example:
function labeledSum(label: string, ...values: number[]): string {
return label + ': ' + values.reduce((acc, v) => acc + v);
}
console.log(labeledSum('total', 1, 2, 3)); // total: 6
Functions That Use this
In functions that use this
, the type of this
can be declared as if it is the first parameter, even though it is not passed that way.
This is a rarely used feature. It is better to define the function as a method in a class.
For example:
type Person = {
name: string;
age: number;
};
function greetTeen(this: Person, greeting: string) {
if (13 <= this.age && this.age <= 19) {
console.log(greeting, this.name);
}
}
const p1: Person = {name: 'Dylan', age: 21};
const p2: Person = {name: 'Paige', age: 16};
greetTeen.call(p1, 'Yo'); // no output
greetTeen.call(p2, 'Yo'); // Yo Paige
Overloaded Functions
TypeScript supports overloaded functions.
In other languages that support overloaded functions (not JavaScript), multiple function definitions with the same name can be written. When called, the implementation to invoke is determined based on the argument types.
In TypeScript, overloaded functions are described by type aliases that define all the acceptable function signatures. But they are all implemented by a single function whose type is this type alias. The function must check the types of its arguments and do the right thing based on those.
If this sounds complicated, it's because it is. For this reason overloaded functions are rarely used in TypeScript.
Generic Functions
Generic functions use type parameters to specify the types they should use. For example, if the built-in Array
map
method were written as a function, it might look like this:
// The parameter "array" is an array of type T.
// The parameter "fn" is a function that takes an
// argument of type T and returns a value of type U.
// This function calls "fn" on each element of "array"
// and returns an array of the results of these calls.
// The return type is an array of type U.
function map<T, U>(array: T[], fn: (item: T) => U): U[] {
const result: U[] = [];
for (const item of array) {
result.push(fn(item));
}
return result;
}
const numbers = [1, 2, 3]; // type inferred as number[]
const d1 = map(numbers, n => n * 2);
console.log(d1); // [2, 4, 6]
const double = (n: number): number => n * 2;
const d2 = map<number, number>(numbers, double);
console.log(d2); // [2, 4, 6]
const words = ['apple', 'banana', 'cherry'];
const lengths = map<string, number>(words, s => s.length);
console.log(lengths); // [5, 6, 6]
The type parameters specified in the calls to the map
function above are not needed because they can be inferred from the arguments.
Classes
Classes provide a template for creating instances using the new
keyword.
For example:
class Person {
name: string;
private age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
const p1 = new Person('Dylan', 21);
TypeScript supports three access modifiers for class members.
public
members can be accessed from anywhere. When no access modifier is specified, this is the default.protected
members can be accessed from all instances of this class and subclasses.private
members can be accessed only from instances of this class.
Access modifiers can be applied to constructor parameters as a shortcut for assigning values to instance properties.
For example:
class Person2 {
// Shortcut for what Person constructor does.
// If public is removed from the name parameter,
// the name instance property will not be set.
constructor(public name: string, private age: number) {}
}
const p2 = new Person2('Paige', 16);
For classes whose constructor does not require parameters, parentheses are not needed when creating instances with new
. However, code formatters like Prettier will add them.
For example:
class Demo {}
const d1 = new Demo;
Unlike in JavaScript, classes in TypeScript must declare all properties.
For example, this class in JavaScript:
class Person {
constructor(name) {
this.name = name;
}
}
can be written like this in TypeScript:
class Person {
name: string; // required
constructor(name: string) {
this.name = name;
}
}
or like this:
class Person {
constructor(public name: string) {}
}
Properties and methods declared with the static
keyword are not associated with any instance and are accessed with the class name. See the example below.
Instance properties declared with the readonly
keyword must set in the constructor and cannot be changed.
For example:
class Widget {
private static count: number = 0;
readonly creationDate: Date;
constructor() {
this.creationDate = new Date();
Widget.count++;
}
static getCount() {
return Widget.count;
}
}
let w = new Widget();
console.log('first Widget =', w);
w = new Widget();
console.log('second Widget =', w);
w = new Widget();
console.log(Widget.getCount()); // 2
Classes can extend one other class to inherit from it. They can override any of the inherited methods.
For example:
class Car extends Vehicle { ... }
Classes can implement any number of interfaces and/or types. They must implement all methods described in them.
For example:
class Car extends Vehicle implements Recyclable, Sortable { ... }
Classes can be marked as abstract only when they are intended to be used as a superclass. Abstract classes cannot be instantiated with the new
keyword.
Extending classes must implement all the methods marked as abstract, or they must also be abstract.
For example:
abstract class Vehicle { ... }
Subclasses must call their superclass constructor from their constructor with super(args)
, even if the superclass has no constructor or has one that doesn’t take arguments. super
must be called before using the this
keyword in the constructor.
Subclass methods can call superclass methods with super.methodName(args)
.
For example:
class Car extends Vehicle {
constructor() {
super();
...
}
report() {
console.log('Car:');
super.report();
}
}
Generic Classes
Generic classes support defining classes whose members have specific types. Code that creates instances of generic classes specifies those types.
For example:
class Pair<T> {
constructor(public first: T, public second: T) {}
}
const p1 = new Pair<number>(3, 19); // generic type can be inferred
console.log(p1); // Pair { first: 3, second: 19 }
const p2 = new Pair('foo', 'bar'); // inferred generic type is string
console.log(p2); // Pair { first 'foo', second: 'bar' }
//const p3 = new Pair('foo', 1); // error, 1 not assignable to string
One example of a built-in generic class is Promise
.
For example;
const promise = new Promise<Person>(resolve => {
// Perhaps retrieve data with a REST call here.
resolve(new Person(...));
});
const person = await promise;
Interfaces
Interfaces can describe method signatures that implementing classes will implement. These can only be instance methods, not static.
Interfaces can also describe properties that instances will have.
For example:
interface Vehicle {
maxSpeed: number;
accelerate(speed: number): void;
}
Unlike abstract classes, interfaces cannot
- specify access modifiers (all members are
public
) - define constructor signatures
- define default method implementations
Interfaces are similar to type aliases that describe object shapes. Both can describe properties and method signatures, but there are three differences:
- Interfaces can extend another interface, a type alias, or a class (weird). Type aliases cannot.
- Interfaces can be defined multiple times in the same scope, and their definitions will be merged. Type aliases cannot.
- The right side of a type alias can be another type (truly an alias).
The bottom line is that there is no strong reason to prefer one over the other for defining an object shape. But it seems the community prefers using interfaces when either will do.
Record
Utility Type
Like Readonly
described earlier, Record
is one of the provided utility types. More are described in the next section.
Record
is used to define a type that is a mapping from a set of keys to values. The keys must be strings, numbers, or symbols. The values can be any type.
For example:
type Fruit = 'apple' | 'banana' | 'cherry' | 'lime';
type Color = 'red' | 'orange' | 'yellow' | 'green' | 'blue';
const fruitMap: Record<Fruit, Color> = {
apple: 'red',
banana: 'yellow',
cherry: 'red',
lime: 'green' // error if this line is missing
};
const color: Color = fruitMap.banana;
console.log(color); // yellow
The benefit of using Record
over using an Object
or Map
is that it provides totality checking (a.k.a. exhaustiveness). This ensures that there is a mapping for all possible keys.
Additional Utility Types
The utility types provided by TypeScript create a new type based on existing types. We have already seen Readonly
and Record
. A summary of the rest of them follows. Note that none of these are used frequently.
-
Partial
This creates a type that is the same asT
, except all the properties are optional. -
Required
This creates a type that is the same asT
, except all the properties are required. -
Pick<T, K>
This creates a type that contains the members ofT
that are listed by name in the union type of string literalsK
. -
Omit<T, K>
This creates a type that contains the members ofT
that are not listed by name in the union type of string literalsK
. -
Extract<T, U>
This creates a type that contains the members ofT
that can be assigned to the typeU
. -
Exclude<T, U>
This creates a type that contains the members ofT
that cannot be assigned to the typeU
. -
NonNullable
This creates a type that is the same asT
, except no properties allow the valuesundefined
andnull
. This considers the type ofT
, but not its members like the previous utility types. It is used to create a more restrictive union type from an existing union type.
The remaining utility types, ReturnType
, InstanceType
, and ThisType
, seem less useful.
Bounded Polymorphism
Bounded polymorphism requires a type parameter to be a type that implements a given interface or extends a given class, rather than just any type.
Consider the differences between these function and class definitions:
// Not using bounded polymorphism.
function foo(bar: Bar): Bar { ... }
// Using bounded polymorphism.
function foo<T extends Bar>(bar: T): T { ... }
// Not using bounded polymorphism.
class Foo {
constructor(bar: Bar) { ... }
}
// Using bounded polymorphism.
class Foo<T extends Bar> {
constructor(bar: T) { ... }
}
In all the cases above, bar
is an object that is assignable to the type Bar
. In the cases that use extends
(bounded polymorphism), the type of T
is the actual subclass of Bar
being used.
One example of when this distinction is useful is when a function must have a return type that matches that of one of its type parameters.
If this seems confusing, that's because it is! It's hard to come up with a good example where bounded polymorphism is needed. Typically the difference is not important, and the non-generic form is used.
Structural Typing
When determining object compatibility, TypeScript uses structural typing, not nominal typing (by name). This means compatibility is based on the properties in an object, not its class.
In the example below, note that the Cat
and Dog
classes have the same properties.
class Cat {
constructor(public name: string) {}
}
class Dog {
constructor(public name: string) {}
}
const cat = new Cat('Whiskers');
dog = cat; // allowed because it has all properties of Dog
const extra = {name: 'Jerry', species: 'mouse'};
dog = extra; // allowed because it has all properties of Dog
const neither = {foo: 'bar'};
//dog = neither; // error: doesn't have all properties of Dog
Flow, an alternative JavaScript type checker, uses nominal typing instead of structural typing. This means it only allows assignments of Dog
objects and objects from Dog
subclasses to variables of type Dog
.
Script vs. Module Mode
JavaScript source files are evaluated in one of two modes, script or module.
Here are some ways in which modules differ from scripts:
- Modules are always executed in strict mode.
- Modules can use
import
andexport
statements. - Modules are loaded once, regardless of how many times they are imported.
- Top-level definitions in modules have file scope, not global scope. They are only accessible outside the file if they are exported.
- The top-level value of
this
in modules isundefined
, notwindow
.
Source files are processed in module mode if they contain at least one import
or export
statement. When neither kind of statement is needed, module mode can be forced by including export {}
, typically at the top of the source file. This is desirable to avoid conflicts between top-level declarations of the same name across multiple source files.
DefinitelyTyped
Many open source libraries include TypeScript type definitions when they are installed from npm. When they don’t, often these are available from DefinitelyTyped which is the "repository for high quality TypeScript type definitions."
Types from DefinitelyTyped are published in npm under the @types
scope. To install type definitions for a library, enter npm install -D @types/library-name
. These type definitions are automatically included by the TypeScript compiler.
To add or correct type definitions in DefinitelyTyped, submit pull requests.
Libraries With No Type Definitions
To get type checking for a library that doesn’t provide its own type definitions and has none in DefinitelyTyped, you can write the type definitions yourself.
When finished there are three options.
- Contribute type definitions to the library.
- Contribute type definitions to DefinitelyTyped.
- Keep the type definitions in your own project, perhaps in the file
src/types.ts
, and import them where needed.
Type Declaration Files
The TypeScript compiler and editors like VS Code use type declaration files for type checking.
These can be generated from TypeScript source files using the command tsc -d name.ts
.
They can also be created manually. This is typically done only for JavaScript libraries that do not supply them. Declare all types that should be visible to consuming code, omitting privately used types.
Type declaration files contain ambient declarations. The term "ambient" distinguishes them from normal declarations.
The declare
keyword is used for every kind of type declaration except interfaces.
The file extension for type declaration files is .d.ts
when there is a corresponding .js
file and can be .d.ts
(preferred) or .ts
otherwise.
Ambient Declaration Examples
Here is a sample TypeScript source file that uses a variety of types:
export const MONTH = 'April';
export let counter: number = 0;
export function double(n: number): number {
return n * 2;
}
export type Fruit = {
name: string;
color: string;
volume: number;
};
export interface Sortable<T> {
sort(): T;
}
export class Person {
constructor(public name: string, public age: number) {}
}
Here is a type declaration file that was generated by running tsc -d
on the source file above:
export declare const MONTH = 'April';
export declare let counter: number;
export declare function double(n: number): number;
export declare type Fruit = {
name: string;
color: string;
volume: number;
};
export interface Sortable<T> {
sort(): T;
}
export declare class Person {
name: string;
age: number;
constructor(name: string, age: number);
}
You can manually create type declarations like these when no TypeScript source is available.
Shipping TypeScript Libraries
The steps to ship a TypeScript library that others can use are as follows:
- Generate type declarations from TypeScript source files.
- Add a
types
key inpackage.json
.
This indicates that type declarations are included. Its value is the path to a.d.ts
file. - Add an npm script in
package.json
.
This should generate new type declarations every time changes are published. - Include source maps.
- Choose the target module format appropriate for code that will use the library.
For code targeted at web browsers this is typically "ES5". - Omit
.ts
files from the deployed library and only include generated.js
and.d.ts
files.
Including.ts
files increases the size of the download for little value. If.ts
files are included and generated.js
files are not, users will have to compile them. The.ts
will still be in a repository such as Git.
In.npmignore
, add thesrc
directory.
In.gitignore
, add thedist
directory.
Migrating JavaScript Projects to TypeScript
There are five steps to migrate a JavaScript project to TypeScript. After each step, run npm run compile
and fix any reported errors.
-
Add the use of tsc.
-
In the top project directory, enter
npm install -D typescript
. -
Add the following to the
compilerOptions
intsconfig.json
:"allowJs": true,
"outDir": "dist"
,"target": "ES5"
Generated files will be written to thedist
directory. -
Add the following
include
option intsconfig.json
:"include": ["src"]
-
Add the following npm script in
package.json
:"compile": "tsc"
-
-
Enable type checking of
.js
files.-
Add the following to the
compilerOptions
intsconfig.json
:"checkJs": true
-
Optionally add
// @ts-nocheck
at the top of.js
files with too many errors to fix now. -
Optionally temporarily add
"noImplicitAny": false
to thecompilerOptions
intsconfig.json
becauseany
will be a commonly inferred type.TypeScript type checking is more lenient on
.js
files. Function parameters are optional. Class property types are inferred based on usage. Extra properties can be assigned to objects.
-
-
Optionally add JSDoc comments
These appear before functions to provide parameter and return types. Supported JSDoc annotations are listed here.
For example:
/**
* description
* @param name {type} description
* @return {type} description
*/
-
Rename
.js
files to.ts
Consider doing this one file at a time, renaming, compiling, and fixing errors. -
Stop processing
.js
files and make type checking strict
Add/change the followingcompilerOptions
intsconfig.json
:"allowJs": false,
"checkJs": false,
"strict": true
Processing Details
When a .ts
file imports a .js
file, it looks for a corresponding .d.ts
file in the same directory as the .js
file. If no such file is found and allowJs
and checkJs
are true, types are inferred. If no such file is found and allowJs
and checkJs
are false, types are treated as any
.
When a .ts
file imports from an npm package (stored locally under node_modules
), it looks for type definitions in src/types.ts
. You can manually create type definitions here.
If src/types.js
is not found, it looks for the types
or typings
key in package.json
of the npm package that points to a .d.ts
file.
If this is not found, it looks for a .d.ts
file in node_modules/@types/package-name
working upward to the top node_modules
directory of the app. This supports nested dependencies.
If this is not found, it uses the lookup process for local (non-npm) imports.
You can override where TypeScript looks for type declarations by setting the typeRoots
option in the compilerOptions
in tsconfig.json
, but doing this is not common.
Whitelisting
When an npm package doesn’t include type definitions and they aren’t available in DefinitelyTyped, there are three options;
- Define the types yourself, perhaps in
src/types.ts
.
Consider contributing your type definitions to the package or DefinitelyTyped. - Whitelist specific imports by preceding them with
// @ts-ignore
. - Whitelist all usages of the package by adding a line in
src/types.ts
.
Example:declare module 'package-name';
Polyfills
tsc
transpiles to many target environments, but it doesn’t provide any polyfills for functionality not present in the target environment.
Popular polyfill libraries include core-js
, @babel/polyfill
, and polyfill.io
.
Babel can be used to get support for language features that TypeScript doesn’t yet support.
Set the lib
option in the compilerOptions
in tsconfig.json
to indicate which features have been polyfilled.
TypeScript With Node.js
When using TypeScript with Node.js, the following tsconfig.json
is recommended as a starting point:
{
"compilerOptions": {
"esModuleInterop": true,
"module": "commonjs",
"noImplicitReturns": true,
"outDir": "dist",
"sourceMap": true,
"strict": true,
"target": "es2015",
}
"include": ["src"]
}
Setting module
to commonjs
compiles import
statements to require
calls and export
statements to module.exports
assignments.
TypeScript With React
To use TypeScript in React projects, configure the use of tsc
as an npm script in package.json
. This is done for you if create-react-app
is used as follows to create the React project:
create-react-app my-app --typescript
React component props and state are defined differently when using TypeScript.
Here is an example of a function component that takes props:
import React from 'react';
type Props = {
name: type,
...
};
function ComponentName(props: Props) {
...
}
export default ComponentName;
Here is an example of a class component that takes props and uses state:
import React, {Component} from 'react';
type Props = {
name: type,
...
};
type State = {
name: type,
...
};
class ComponentName
extends Component<Props, State> {
state = {
name: type,
...
};
render() {
...
}
}
export default ComponentName;
Use React’s synthetic event types instead of DOM event types. Type definitions for these can be found here. They include ChangeEvent
, FocusEvent
, KeyboardEvent
, MouseEvent
, TouchEvent
, WheelEvent
, and more.
When using the useState
hook, if TypeScript can’t infer the type from the initial value, specify it like this:
const [name, setName] = useState<type>(initialValue);
Features Coming in TypeScript 3.7
There are many new features coming in TypeScript 3.7. These include optional chaining, null coalescing, and top-level await
.
Here is an example of optional chaining:
// Old way
const zip = person && person.address ? person.address.zip : undefined;
// New way
const zip = person?.address?.zip;
Here is an example of null coalescing:
// Old way - chooses option2 if option1 is any falsy value
// including undefined, null, false, zero, or empty string
const option = option1 || option2;
// New way - chooses option2 only if option1 is undefined or null
const option = option1 ?? option2;
Top-level await allows the use of the await
keyword to wait for a Promise
to resolve at the top level of code, not just inside async
functions.
private
vs. #
Prefix Fields
"Public and private instance fields" is a TC39 proposal that is currently at stage 3. This defines a way to mark instance properties of a class as private by prefixing their names with a #
character.
TypeScript does not yet support this, but work to do so is underway. See https://github.com/microsoft/TypeScript/pull/30829.
There are some differences between the meaning of the private
keyword and the #
field name prefix.
- When are they evaluated?
Private fields are evaluated only at compile time and do not generate code to protect accesses.#
prefix fields generate code to protect access at runtime. - What happens when a subclass and superclass define a field with the same name?
Duplicate private fields represent the same value.#
prefix fields represent different values. - Are they visible in
console.log
andJSON.stringify
output?private
fields are visible, but#
prefix fields are not.
Do's and Don'ts
The page at https://www.typescriptlang.org/docs/handbook/declaration-files/do-s-and-don-ts.html provides recommendations on things you should and should not do in TypeScript.
One of the recommendations is to never use the wrapper types Boolean
, Number
, String
, Symbol
, and Object
.
See this site for additional recommendations.
Things to Avoid
TypeScript has a large number of features. While all of them have some justification, many are rarely used. This increases the likelihood that many developers will not be familiar with them. To reduce the learning curve of TypeScript for your team and improve code readability, I recommend avoiding the use of the following TypeScript features.
- Tuples with a minimum length and no maximum using the spread operator
Example:type badIdea = [number, number, ...number[]] // contains 2 or more numbers
- Implementing functions that use
this
... and declaring the type of this as the first parameter rather than implementing it as a method of a class - Overloaded functions
- Bounded polymorphism with multiple constraints
- Generic type defaults
- Using
this
as a return type of a method in a superclass - Intersection types
These match only what multiple types have in common. It's hard to think of a good example of when this would be useful. - Interfaces extending a type alias or class
- Comparing classes
- Defining a constructor signature in an
interface
withnew
- Decorators
These are currently an experimental feature. private
constructors to simulate final classes
Such classes cannot be extended or directly instantiated (must use static factory methods).as const
assertions- Type assertions
- Tagged Unions
- Keying-in operation
This extracts a shape type from another type. keyof
operator
This gets property names from a shape.- Mapped types, including built-in ones
- Companion object pattern
- Creating tuples with a function
... instead of using the[]
syntax. - User-defined type guards
- Conditional types, including built-in ones
- Distributive conditionals
infer
keyword- Non-null assertions
These just assert that a value is not null. - Definite assignment assertions
- Type branding
- Extending prototypes
- Dynamic imports
- TypeScript namespaces
- Declaration merging
- Project references
- Triple-slash directives
amd-module
directive- Utility types other than
Record
andReadonly
Conclusion
TypeScript is a large language with a wonderful core set of features. By limiting yourself to the most important set of features, you will write code that derives most of the benefits of TypeScript while keeping the code as readable as possible.
Thanks so much to Charles Sharp and Jason Schindler for reviewing this article!
References
- Cherny, Boris. Programming TypeScript. USA: O'Reilly, 2019
Software Engineering Tech Trends (SETT) is a regular publication featuring emerging trends in software engineering.