Originally published October 2018. Updated March 2023. This article describes the features and functionality of TypeScript 5.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 make 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. TypeScript 5.0 allows you to extend multiple settings files (specified as an array), with latter entries overriding earlier ones. This provides additional flexibility in structuring larger projects that may require different settings for independent areas while still maintaining a common config root.
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.
As TypeScript has now been around for over a decade, some configuration options are no longer relevant in modern projects given the current state of language targets, runtimes, and tooling. TypeScript 5.0 deprecates some legacy tsconfig settings and projects still using them will have warnings issued when running tsc
. Projects are advised to migrate away from these during the TS 5.x release cycle as they will eventually throw errors in a future TypeScript release (likely 6.0).
Syntax and JavaScript support
TypeScript supports current JavaScript syntax (through ES2022), 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:
- for-await-of loop iteration (TS 2.3, ES3/ES5 with downlevelIteration)
- Dynamic import expressions (TS 2.4)
- Optional catch clause variables (TS 2.5)
- import.meta (TS 2.9)
- Optional chaining (TS 3.7)
- Nullish coalescing (TS 3.7)
- Private class fields (TS 3.8, ES2020, only supported for ES2015+ targets)
- export * as ns syntax (TS 3.8, ES2020)
- import assertions (TS 4.5)
- TC39 Decorators (TS 5.0, although experimental pre-TC39 decorators have been supported for several versions)
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 ES5 code and assumes an ES5-compatible standard library. Note that the default target since TypeScript’s inception and prior to TS 5.0 was ES3, however, this target is now deprecated and teams still using it will encounter compiler warnings prompting them to upgrade to newer targets. So, for example, arrays won’t have an includes
method. Setting the target compiler option to “es2016” (or “ES2016”; most TS option values aren’t case-sensitive) will instruct the compiler to emit ES2016 code, and also causes it to load several built-in ES2016 type libraries, one of which includes typings for Array.prototype.includes
.
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.includes
, 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 module resolution
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, however modern projects will rarely encounter this. For any other module type, it defaults to its “node” strategy. The strategy can be manually set with the moduleResolution config option.
TypeScript 5.0 introduces a new module resolution strategy of bundler
. As its name suggests, this strategy aims to replicate the more flexible resolution rules of modern application toolchains that rely on bundlers or TypeScript-native runtimes. It is closer to the existing node
strategy without restrictions introduced by the node16
/nodenext
strategies that TypeScript 4.7 brought in, such as requiring file extensions in relative imports.
The node
strategy emulates 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.
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 baseUrl, paths, and rootDirs options can be used to further configure where the compiler looks for absolutely-referenced modules. Starting in TypeScript 4.7, you can use the moduleSuffixes compiler option to give the compiler additional file name suffixes to use when resolving modules.
{
"compilerOptions": {
"moduleSuffixes": [".ios", ".native", ""]
}
}
import MyClass from "./myModule";
Given the compiler options setting above, the import statement would cause the compiler to look for files named “myModule.ios.ts”, “myModule.native.ts”, and “myModule.ts”, stopping with the first one found.
TypeScript 5.0 introduces several more compiler options to allow even finer-grained resolution customization if needed: allowImportingTsExtensions
, resolvePackageJsonExports
, resolvePackageJsonImports
, allowArbitraryExtensions
, and customConditions
.
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 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: <a href="https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#any" target="_blank" rel="noreferrer noopener">any</a>
, string
, number
, 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 <a href="https://www.typescriptlang.org/tsconfig#noImplicitAny" target="_blank" rel="noreferrer noopener">noImplicitAny</a>
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.
String Literal Types
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
.
Template literal types
Template literal types, added in TypeScript 4.1, built upon the foundation established by string literal types. While string literal types must be represented by fixed strings, template literal types can derive their values using syntax that is very similar to template literals. Consider the scenario of describing the alignment of one element to another. To fully describe the possibilities, the horizontal and vertical dimensions must both be addressed. This might lead to the following types:
type VerticalAlignment = "top" | "middle" | "bottom";
type HorizontalAlignment = "left" | "center" | "right";
Template literal types allow a function to be defined that can only accept a VerticalAlignment type concatenated to a HorizontalAlignment type with a dash separating the two values as shown here:
declare function setAlignment(value: `${VerticalAlignment}-${HorizontalAlignment}`): void
The setAlignment
function declared above will only accept valid strings without having to explicitly list each of the nine possible combinations of horizontal and vertical alignment.
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 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;
}
By default, optional properties are treated as having a type like “[original type] | undefined”. So in the previous example, toGeo
would be of type Point | undefined
. This means that you can define a Point
object like,
const p: Point = {
toGeo: undefined
}
Normally this would be OK, but some built-in functions like Object.assign
and Object.keys
behave differently whether or not a property exists (and is undefined) or not. As of TypeScript 4.4, you can now use the option exactOptionalPropertyTypes
to tell TypeScript to not allow undefined values in these cases.
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
. Before TypeScript 4.4, as in JavaScript, it is only possible to use string
or number
as the type of the index signature. As of TypeScript 4.4 however, index signatures can also include Symbols and template string patterns.
const serviceUrl = Symbol("ServiceUrl");
const servicePort = Symbol("ServicePort");
interface Configuration {
[key: symbol]: string | number;
[key: `service-${string}`]: string | number;
}
const config: Configuration = {};
config[serviceUrl] = "my-url";
config[servicePort] = 8080;
config["service-host"] = "host";
config["service-port"] = 8080;
config["host"] = "host"; // error
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.
TypeScript 4.2 improved tuples by allowing ...rest
elements to be at any position in a tuple type.
let bar: [boolean, ...string[], boolean];
There are restrictions. A rest element cannot be followed by another rest element or an optional element. There can only be one rest element in a tuple type.
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 when 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 Dog
, 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<a SpecificType>
. For example, an “array of strings” type would be Array<a string>
, and a “promise that resolves to a number” type would be Promise<a number>
. Generic types may require more than one specific type, like Converter<a 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<T = string>(...args: T[]): Arrayish<T> {
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 typically referred to as a type guard; these will be discussed in more detail later in this article.
TypeScript 4.4 enhanced type guards such that instanceof
and typeof
checks can now be assigned to constant values. For example:
function performSomeWork(someId: number | undefined) {
const hasSomeId = typeof someId === "number";
if (hasSomeId) {
// someId is a number
// perform some work
}
// someId is a number | undefined
// perform some work
if (hasSomeId) {
// someId is a number
// perform more work
}
}
When declaring a generic type as a union type, a default type can be included.
type Data<T extends string | number = number> = { value: T };
const data1: Data = { value: 3 };
const data2: Data = { value: '3' }; // error, value has to be a number
const data3: Data<number> = { value: 3 };
const data4: Data<string> = { value: '3'};
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
TypeScript 4.1 continued to expand the capability of mapped types by adding the ability to remap the keys into a new type using the as keyword. This feature allows, among other things, the acceptable keys to be derived from the keys of a generic source using template literal types. Consider the following interface:
interface Person {
name: string;
age: number;
location: string;
}
By combining template literal types and re-mapping, we can create a generic getter type whose keys represent accessor methods for the source type’s fields.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};
type LazyPerson = Getters<Person>;
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 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, among other ways of narrowing types. Inside 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
}
}
As of TypeScript 4.5, template string types can be used as type guards.
interface Data {
type: ${string}Result;
data: string;
}
interface Failure {
type: ${string}Error;
message: string;
}
function handleResponse(response: Data | Failure) {
if (response.type === 'DatabaseResult') {
// Response type is narrowed to Data.
handleDatabaseData(response.data);
} else if (response.type === 'DatabaseError') {
// Response type is narrowed to Failure.
handleDatabaseError(response.message);
}
}
TypeScript 4.7+ will narrow the type of a bracketed element.
type Data =
| { kind: "NumberData", value: number }
| { kind: "StringData", value: string };
function processData(data: Data) {
if (data.kind === "NumberData") {
let num = data.value; // value is a number
} else {
let str = data.value; // value is a string
}
}
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.
ECMAScript private fields provide what is known as a “brand check”. A class’s code can try to access a private field in an object. A value will be retrieved if the object was created using the class’s constructor, otherwise, an exception will be thrown. EC2022 and TypeScript 4.5 allows the in
operator to check for the existence of a private field without generating an exception. For example, in the Animal
class above, the following static method could be added:
static isAnimal(object: any): object is Animal {
return #secretId in object;
}
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();
}
As of TypeScript 4.4, you can use static blocks to initialize static class fields. This is particularly useful if you need to perform some initialization logic on your fields.
class Configuration {
static host: string;
static port: number;
static {
try {
const config = parseConfigFile();
Configuration.host = config["host"];
Configuration.port = config["port"];
} catch (err) {
Configuration.host = "default host";
Configuration.port = 8080;
}
}
}
Static blocks are also a great way to initialize private static fields that can’t be accessed outside of the class.
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;
}
}
You must call super
in a constructor before any references to this
. In TypeScript 4.6+, you are allowed to run code before a call to super
as long as that code does not reference this
.
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 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 the 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 are 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 other literals.
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’. Literal-initialized enums cannot be reverse mapped.
In earlier versions of TypeScript, numeric and literal enums had a few other subtle differences that could cause confusion. Numeric enum values effectively had the same type as the containing enum itself but were just numbers in any other context, which allowed for their values to be computed, and for instances of the enum to be initialized from numeric values rather than member names. Members of literal enums could not be computed, and each had their own distinct type, with the containing enum type being a union of all its member types. TypeScript 5.0 unified these two enum variations meaning all enum types are unions of their member types, and computed member values are allowed in enum literals.
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 this:
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 <a href="https://www.typescriptlang.org/tsconfig#allowUnreachableCode" target="_blank" rel="noreferrer noopener">allowUnreachableCode</a>
and <a href="https://www.typescriptlang.org/tsconfig#noFallthroughCasesInSwitch" target="_blank" rel="noreferrer noopener">noFallthroughCasesInSwitch</a>
.
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 have 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.
Types in try/catch statements
By default, values captured in a catch statement are defined as an any
type.
try {
throw "error";
} catch (err) {
// err is "any" type
}
As of TypeScript 4, you can declare these values as unknown types, since that type fits better than any
.
try {
throw "error";
} catch (err: unknown) {
// err is "unknown" type
}
In TypeScript 4.4, you can make the values captured in catch statements be defined as unknown
by default by enabling the useUnknownInCatchVariables
configuration option. This option is enabled by default when using the strict
option.
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 documentation hub is the authoritative resource on the language itself, particularly the handbook and reference guides which provide additional insight above and beyond what this guide provides. Stack Overflow is also an excellent place to discuss TypeScript and ask questions.
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!