What is TypeScript
TypeScript is a popular JavaScript superset created by Microsoft that brings a type system on top of all the flexibility and dynamic programming capabilities of JavaScript.
The language has been built as an open-source project, licensed under the Apache License 2.0, has a very active and vibrant community, and has taken off significantly since its original inception.
Installing TypeScript
To get started with TypeScript and try out all the examples, you can either install the TypeScript transpiler on your computer (more about this in the following paragraph), use the official online playground or any other online solution you prefer.
In case you want to try the examples locally, you need to install the command-line transpiler, which runs on Node. First, you need to install Node.js and npm on your system. Then, you can create a Node.js project and install the TypeScript transpiler package:
# Create a new directory for your project mkdir typescript-intro # Make your project directory the current directory cd typescript-intro # Initialize a new Node.js project npm init -y # Install the TypeScript compiler npm i typescript
This will install the
tsc
(TypeScript Compiler) command in the current project. To test the installation, create a TypeScript file called index.ts
under your project directory with the following code:console.log(1);
Then, use the transpiler to transform it to JavaScript:
# transpiling index.ts to index.js npx tsc index.ts
This will generate a new file called
index.js
with the exact same code of the TypeScript file. Use the node
command to execute this new file:# this will output 1 node index.js
Although the transpiler did nothing else besides creating a JavaScript file and copying the original code to it, these steps helped you validate that your TypeScript installation is in good shape and ready to handle the next steps.
Note: TypeScript versions can have substantial differences even though they get released as minor revisions. It's common to bump into transpilation problems after a minor version update. For that reason, it is better to install TypeScript locally in your project and execute it using
when needed instead of relying on a global TypeScript installation.npx
Defining a TypeScript Project
To define a TypeScript project within your Node.js project, you need to create a
tsconfig.json
file. The presence of this file in a directory denotes that the directory is the root of a TypeScript project.tsconfig.json
contains a number TypeScript configuration options that change the transpiler's behavior, such as which files to check or ignore, the transpilation target, the imported types, among many others.You can create the TypeScript configuration file easily by running the following command:
# create a default tsconfig.json file npx tsc --init
The generated
tsconfig.json
file contains almost all available options with a brief description of what they let you accomplish. Fortunately, most of these options have a good default value, so you can remove most of them from your file.This blog post will spend some time talking about the compiler options later on. For now, let's focus on writing some code.
TypeScript Features
The features of TypeScript are thoroughly explained in the TypeScript handbook. However, this article will focus on a more practical approach to some of these features. It will also give light to some features that are often left out from content you find on the internet.
Typing fundamentals
TypeScript's basic idea is to keep the dynamism and flexibility of JavaScript under control through the usage of types. Let's see this concept in action through a practical exercise.
Create a file called
test.js
under your project directory and populate it with the following code:const addOne = (age) => { return age + 1; }; const age = "thirty two"; console.log(addOne(age)); console.log(addOne(20));
Execute that file as follows:
node test.js
- What was the output of the program?
- Do you think the output is correct?
It turns out that running it on Node.js, or on any browser for that matter, would output
thirty two1
without generating any warning. Nothing new here; it's just JavaScript behaving as flexible as always.But, what if you want to guarantee that the
addOne()
function accepts only numbers when called? You could change the code to validate the parameters typeof
during runtime, or you could use TypeScript to restrict that during compile time.Head back to the
index.ts
file you created earlier and replace its content with the following:const addOne = (age: number): number => { return age + 1; }; console.log(addOne(32)); console.log(addOne("thirty two"));
Note that you are now restricting the parameter
age
to only accept values of type number
as valid.Transpile the file again:
npx tsc index.ts
Using the TypeScript compiler to generate JavaScript produces the following error:
index.ts:6:20 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. 6 console.log(addOne("thirty two")); ~~~~~~~~~~~~ Found 1 error.
Defining types during application design can help you avoid mistakes like passing the wrong variable type to functions.
string
and number
are two of the basic types that TypeScript supports. Besides these, TypeScript supports all the JavaScript primitive types, including boolean
and symbol
.On top of these, TypeScript defines some types that do not map to anything in JavaScript directly but are very useful to represent some of the methodologies that are commonly used in the ecosystem:
is a constrained set of values.enum
indicates that a variable/parameter can be anything, effectively skipping the type system.any
is the type-safe counterpart ofunknown
.any
indicates that a function won't return anything.void
indicates that a function always throws an exception or never finishes its execution.never
- Literal Types are concrete subtypes of
,number
, orstring
. What this means is that "Hello World" is aboolean
, but astring
is not "Hello World" inside the type system. The same goes withstring
in the case of booleans orfalse
for a number:3
declare function processNumber(s: 3 | 4); // This function won't accept a number, but only 3 or 4. declare function processAnyNumber(n: number); const n: number = 10; const n2: 3 = 3; processNumber(n); // Error: number is not 3 | 4 processAnyNumber(n2) // Works. 3 is still a number
Aggregates
TypeScript supports aggregate types (maps, array, tuples), allowing a first-level of type composition:
Maps
Maps are commonly used to manage an association of keys to values and to represent domain application data:
// Creates a map-like type type User = { id: number, username: string, name: string }; // Creates an instance of the user object const user: User = { id: 1, username: "Superman", name: "Clark Kent", };
Vectors
Vectors are a sequential and indexed data structure that has a fixed type for all its elements. While this is not a feature that JavaScript supports, TypeScript's type system allows developers to emulate this concept:
// Creates a map-like type type User = { id: number; username: string; name: string; }; // Creates instances of the user object const user1: User = { id: 1, username: "Superman", name: "Clark Kent", }; const user2: User = { id: 2, username: "WonderWoman", name: "Diana Prince", }; const user3: User = { id: 3, username: "Spiderman", name: "Peter Parker", }; // Create a vector of users const userVector: User[] = [user1, user2, user3];
Tuples
Tuples are also a sequential indexed data structure, but its elements' type can vary according to the fixed definition:
// Creates a map-like type type User = { id: number; username: string; name: string; }; // Creates instances of the user object const user1: User = { id: 1, username: "Superman", name: "Clark Kent", }; // Create a user tuple const userTuple: [User, number] = [user1, 10];
Unions
Another way to compose types is through unions which are very handy when a function argument can have multiple types.
Suppose you want to write a function that will fetch the user's address details using either a
User
object or a string
representing an email address.First of all, let's install
node-fetch
in our project so that we can use the fetch
function:npm i node-fetch @types/node-fetch
…and then in the code, we can discriminate the two cases by type using the
typeof
operator:import fetch from 'node-fetch'; type User = { id: number, username: string, name: string email: string }; async function fetchFromEmail(email: string) { const res = await fetch('https://jsonplaceholder.typicode.com/users'); const parsed: User[] = await res.json(); const user = parsed.find((u: User) => u.email === email); if (user) return fetchFromId(user.id); return undefined; } function fetchFromId(id: number) { return fetch(`https://jsonplaceholder.typicode.com/users/${id}`) .then(res => res.json()) .then(user => user.address); } function getUserAddress(user: User | string) { if (typeof user === 'string') return fetchFromEmail(user); return fetchFromId(user.id); } getUserAddress("Rey.Padberg@karina.biz") .then(console.log) .catch(console.error)
The type system is smart enough that note that, according to the
if
result, the type under check is a string or not; this is an implicit type guard. Let's take a look at that.As side note, tuples and unions play well together:
const userTuple: Array<User | number> = [u, 10, 20, u, 30]; // Any item can be either an User or a number
It is also possible to specify both the size and the type of every element in the array:
const userTuple: [User, number] = [u, 10, 20, u, 30]; // Error: the array must have a size of 2 and be an User and a number const anotherUserTuple: [User, number] = [u, 10]; // Correct
Type guards
Type guards are expressions that perform a runtime check whose result can be used by the type system to narrow the scope of the checked argument.
The
typeof
operator is a type guard; in the previous example, it has been used to narrow down the scope of the user
argument.There are other expressions that TypeScript treats as type guards, such as
instanceof
, !==
and in
; the documentation has the complete listTo handle situations where the type system is not able to infer the specific type in the current scope, it is possible to define custom type guards through a predicate (a typed function returning a boolean):
// Define a type guard for the user function isUser(u: unknown): u is User { if (u && typeof u === 'object') return 'username' in u && 'currentToken' in u; return false; } function getUserAddress(user: User | string) { if (isUser(user)) return fetchFromEmail(user); return fetchFromId(user.id); }
User defined type guards are completely under the developer's control, and TypeScript has no way to verify their correctness.
A very common and legit use case for custom type guards is when validating external data against a JSON Schema through an external library, such as Ajv. This usually happens in web applications where the request body is typed as
unknown
(or any
, depending on the framework you're using), and we want to type-check it before moving on with its processing:import Ajv from "ajv"; const ajv = new Ajv(); const validate = ajv.compile({ type: 'object', properties: { username: { type: 'string' }, currentToken: { type: 'string' } }, }); function validateUser(data: unknown): data is User { return validate(data); }
This mechanism relies upon the developer's discipline of keeping the JSON Schema definition in sync with the type. In fact, if we modify the type but not the JSON Schema, we would get TypeScript narrowing a type to something that it's not.
We'll see in a different section an alternative to keep these up to date automatically.
Discriminated unions
Unions with a common literal field are called discriminated unions. When working with these, TypeScript is able to provide an implicit type guard, avoiding the burden of writing a custom one:
type Member = { type: 'member', currentProject: string }; type Admin = { type: 'admin', projects: string[] }; type User = Member | Admin; function getFirstProject(u: User) { if (u.type === 'member') return u.currentProject; return u.projects[0]; }
You can see in the
getFirstProject
function TypeScript can narrow the scope of the argument without having to write any predicate. Trying to access the projects
array in the first branch or currentProjects
in the second branch would result in a type error.Runtime Validations
We have briefly explained how, in case of custom-type guards, it is up to the developer to test and make sure that the returned result is correct.
In case of bugs in the predicate, the type system will have inaccurate information. Consider the following code snippet:
function validateUser(data: unknown): data is User { return true; }
The following predicate will always return true — effectively leading the type checker narrowing a type on something that it is not:
const invalidUser = undefined; if (validateUser(invalidUser)) { // The previous statement always returns true console.log(invalidUser.name); // Runtime error }
TypeScript has a set of libraries that can help us to keep the runtime validation in sync with the associated type automatically, providing an implicit type guard we do not have to manage. A notable one is runtypes, but in this article, we're going to take a look at io-ts.
Essentially the deal is to define the shape of a type using io-ts included primitives; that defines a decoder we can use in our application to validate data we do not trust:
Once we have installed the required dependency
npm i io-ts
We can try the following code:
import * as D from 'io-ts/Decoder'; import * as E from 'io-ts/Either'; import { pipe } from 'fp-ts/function'; // Define a decoder representing an user const UserDecoder = D.type({ id: D.number, username: D.string, name: D.string email: D.string }); // Use the decoder on data we do not trust pipe( UserDecoder.decode(data), E.fold( error => console.log(D.draw(error)), decodedData => { // decodedData's type is User console.log(decodedData.username) } ) );
TypeScript Configuration
The transpiler's behavior can be configured through a
tsconfig.json
file that indicates the root of a project.In particular, the file contains a series of key values controlling 3 main parts:
- The project structure, such as what files to include and exclude from the transpiling process, what are the dependencies of the various TypeScript projects, and how these projects can refer through each other through aliases.
- Type checker behavior, such as whether to check or not for
andnull
in the codebase, preserve theundefined
, and so onconst enums
- Runtime transpilation process.
TSConfig presets
TypeScript's transpiler can produce down to ES3 code and supports multiple module definitions (CommonJS, SystemJS).
The combination of the two is dependent on the runtime environment that you're using. For instance, if you're targeting Node 10, you can comfortably transpile to
ES2015
and use CommonJS
as for the module resolution strategy.In case you're using a newer Node runtime, such as 14 or 15, then you can target
ESNext
or ES2020
and even dare to use the ESNext
module strategy.Finally, if you're targeting the browser and you're not using a module bundler such as
wepack
or parcel
, you might want to use UMD
.Fortunately, the TypeScript team provides good presets that you can import in your own
tsconfig
file that handles most of these parameters for you. Using them is relatively straightforward:{ "extends": "@tsconfig/node12/tsconfig.json", "include": ["src"] }
Notable configuration options
: controls whether TypeScript should produce declaration files (declaration
) with the transpilation. If your project is a library, it's good to enable this so that other developers using your code can benefit from the type checking. If the project is a deployable artifact, such as a web application, you can set this to false.d.ts
: controls whether TypeScript should abort the transpilation in case there is a type error. If set to false, the type erasure and the JavaScript production will continue anyway. Generally speaking,noEmitOnError
is the value to usetrue
: true,removeComments
: true,suppressImplicitAnyIndexErrors
: controls a family of additional type checks. Unless there are some good reasons (such as legacy libraries that haven't been correctly migrated/typed), disabling this is not a good idea.strict
: When necessary, TypeScript will emit functions and helpers to polyfills newer features that are not available in older standards, such as ES3 and ES5. If set to false, these helpers will be put at the beginning of your code; otherwise, they will be omitted, and you can install thenoEmitHelpers
dependency separately.tslib
Conclusions
Unlike most of the other introduction articles on TypeScript, hopefully, this one gave you a different perspective on the capabilities that are often ignored in TypeScript.
While not perfect, TypeScript's type system is pretty powerful and, for the people interested in using it to the extreme — I highly recommend taking a look at fp-ts and io-ts.
About the author
Vincenzo Chianese
API Architect