developers

Working With TypeScript: A Practical Guide for Developers

TypeScript Practical Introduction

Apr 27, 202115 min read

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

npx
when needed instead of relying on a global TypeScript installation.

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
  1. What was the output of the program?
  2. 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:

  • enum
    is a constrained set of values.
  • any
    indicates that a variable/parameter can be anything, effectively skipping the type system.
  • unknown
    is the type-safe counterpart of
    any
    .
  • void
    indicates that a function won't return anything.
  • never
    indicates that a function always throws an exception or never finishes its execution.
  • Literal Types are concrete subtypes of
    number
    ,
    string
    , or
    boolean
    . What this means is that "Hello World" is a
    string
    , but a
    string
    is not "Hello World" inside the type system. The same goes with
    false
    in the case of booleans or
    3
    for a number:
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 list

To 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:

  1. 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.
  2. Type checker behavior, such as whether to check or not for
    null
    and
    undefined
    in the codebase, preserve the
    const enums
    , and so on
  3. 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

  • declaration
    : controls whether TypeScript should produce declaration files (
    .d.ts
    ) 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
  • noEmitOnError
    : 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,
    true
    is the value to use
  • removeComments
    : true,
  • suppressImplicitAnyIndexErrors
    : true,
  • strict
    : 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.
  • noEmitHelpers
    : 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 the
    tslib
    dependency separately.

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.