Photo by Danielle Cerullo on Unsplash

TypeScript: JavaScript on Steroids?

Frank Stepanski
40 min readJul 3, 2023

When it burns, it grows — Arnold.

This article assumes you have a solid knowledge of JavaScript.

In today’s technology landscape, JavaScript has become ubiquitous, powering applications across various platforms, from web browsers to household appliances. However, as our applications grow in size and complexity, the inherent dynamic nature of JavaScript can lead to a common challenge: uncovering bugs only during runtime, making our codebases fragile and challenging to maintain.

Enter TypeScript, open-sourced in 2012, a typed superset of the JavaScript language created and maintained by Microsoft. Built by Anders Hejlsberg.

This means that it extends JavaScript, adding a number of helpful and productive features to the language, without changing how it works.

Any JavaScript program is also a valid TypeScript program.

Why TypeScript?

Types are foundational to JavaScript; they influence everything about the way you write your programs. But JavaScript’s dynamic type system often makes it easy to introduce bugs by mistakenly referencing variables with the wrong type.

When JavaScript throws exceptions or performs implicit type conversions, it does so at runtime. This means you have to actually run your program to get a useful signal back that you did something invalid.

TypeScript was invented to stop these problems from happening before you even run your code. It uses JavaScript as a base, adds some extra syntax to define types, and provides a tool to transform your code back into JavaScript. The result is a hybrid of a compiler and a linter.

Type Validation

TypeScript serves as a static validation tool similar to ESLint.

The primary objective of such validation tools is to enhance developers’ confidence in their code by ensuring that it behaves as intended and avoids unexpected bugs. TypeScript, with its understanding of type relationships within the codebase, can effectively eliminate certain types of errors and issues. By leveraging TypeScript effectively, developers can significantly reduce the likelihood of encountering specific classes of errors and improve the overall quality and reliability of their code.

ESLint primarily focuses on code style, best practices, and identifying potential issues related to JavaScript syntax. On the other hand, TypeScript goes beyond code style and syntax. It empowers developers to catch type-related errors during the development process, enhancing the robustness of their code.

The good news is that you can combine the benefits of both ESLint and TypeScript by adding TypeScript-specific linting rules to ESLint. This allows you to perform TypeScript-specific checks such as identifying missing return types, validating type inference behavior, detecting invalid TypeScript syntax, and even enforcing proper type assertions.

Compiler

TypeScript is also a compiler. Like Babel, it can transform TypeScript and JavaScript code to support different features for older JavaScript engines. Otherwise, the compiler will strip out the type data and leave you with valid JavaScript.

It’s important to remember that TypeScript is not a runtime language — all of the types are removed at compile time. Its purpose is to encourage the developer to use the types to add checks to your program so you have fewer errors and bugs.

JavaScript Superset

Since TypeScript is a superset of JavaScript, many of the quirks of JavaScript are present in TypeScript programs.

It might not always be possible to add the appropriate type annotations to something. TypeScript provides escape hatches, like the any type and // @ts-ignore which turns off the type checker for those parts of your code.

But please, use with caution. ⚠️

// @ts-ignore
const myVariable: number = "hello"; // Ignored type checking error

// @ts-ignore
{
const myVariable: string = 123; // TypeScript will ignore the type error in this block
const anotherVariable: number = "hello"; // TypeScript will also ignore this type error
}

Types

When comparing types in JavaScript, it can be helpful to use the typeof operator. When placed in front of a value, it gives you a string representation of that value's type.

typeof true; // "boolean"

let fruitName = "Banana";
typeof fruitName; // "string"

There are six primitive data types in JavaScript: Boolean, Number, BigInt, String, Symbol, and undefined. A primitive type represents a single specific type of value and is immutable, meaning we can't directly modify its value, we can only reassign a different value to a variable.

The types and type operators for JavaScript are the same in TypeScript.

The Type System

There are generally two kinds of type systems: type systems in which you have to tell the compiler what type everything is with explicit syntax and type systems that infer the types of things for you automatically. Both approaches have trade-offs.

TypeScript is inspired by both kinds of type systems: you can explicitly annotate your types, or you can let TypeScript infer most of them for you.

To explicitly signal to TypeScript what your types are, use annotations. Annotations take the form of value: type

let a: number = 1                // a is a number
let b: string = 'hello' // b is a string
let c: boolean[] = [true, false] // c is an array of booleans

Dynamic type binding means that JavaScript needs to actually run your program to know the types of things in it. JavaScript doesn’t know your types before running your program.

TypeScript is a gradually typed language. That means that TypeScript works best when it knows the types of everything in your program at compile time, but it doesn’t have to know every type in order to compile your program.

Even in an untyped program TypeScript can infer some types for you and catch some mistakes, but without knowing the types for everything, it will let a lot of mistakes slip through to your users.

This gradual typing is really useful for migrating legacy codebases from untyped JavaScript to typed TypeScript.

TypeScript statically analyzes your code for errors and shows them to you before you run it. If your code doesn’t compile, that’s a really good sign that you made a mistake and you should fix it before you try to run the code.

Why Developers Want Types

Developers value types in their programming language because they allow them to express their intent directly on the page.

By providing explicit type annotations, developers can document the expected behavior and structure of their code, leaving clear instructions for themselves and other collaborators. This clarity not only improves code comprehension but also helps catch potential errors early during development.

Types serve as a form of self-documentation.

This kind of intent is often missing from JS code. For example

function add(a, b) {
return a + b
}

Is this meant to take numbers as args? strings? both?

What if someone who interpreted a and b as numbers made this “backward-compatible change?”

function add(a, b, c = 0) {
return a + b + c
}

We’re headed for trouble if we decided to pass strings in for a and b!

Types make the author’s intent more clear:

function add(a: number, b: number): number {
return a + b
}
add(3, "4") // Argument of type 'string' is not assignable to parameter of type 'number'.

It has the potential to move some errors from runtime to compile time.

Under the Hood

Before we get knee-deep in TypeScript, let’s get under the hood a little bit.

Programs are files that contain a bunch of text written by you, the programmer. That text is parsed by a special program called a compiler, which transforms it into an abstract syntax tree (AST), a data structure that ignores things like whitespace, comments, etc.

The compiler then converts that AST to a lower-level representation called bytecode. You can feed that bytecode into another program called a runtime to evaluate it and get a result.

  1. The program is parsed into an AST.
  2. AST is compiled to bytecode.
  3. Bytecode is evaluated by the runtime.

Where TypeScript is special is that instead of compiling straight to bytecode, TypeScript compiles to JavaScript code. You then run that JavaScript code like you normally would — in your browser or with NodeJS.

But where does the type checking happen?

The TypeScript Compiler generates an AST for your program — but before it emits code — it type checks your code.

Want to see an AST up close? Check out an online tool like AST Explorer.

Getting Started

You can run TypeScript on your computer as long as you have NodeJS installed. You can either install TypeScript globally or locally per project.

The main difference between locally and globally installed TypeScript is that a project will use its own specific version making sure there are no versioning issues.

Many resources mention “the easiest way to get started with TypeScript

Installing locally

  1. Create a node project with npm init
  2. Install the TypeScript package as a dev dependency:
npm install typescript --save-dev

3. Create an npm script in your package.jsonfile:

"scripts": {
"dev": "tsc --watch --preserveWatchOutput"
},

TSC is the TypeScript command line tool which we run to type check and compile our TypeScript into JavaScript. If we have a tsconfig.json file in our project directory, tsc will pick up all of the configuration options we used in that file.

This command runs the compiler in “watch” mode which will watches for source changes and rebuilds automatically. 👀

4. Now we need a tsconfig.json configuration file:

tsc --init

A tsconfig.json file declares the settings that TypeScript uses when analyzing your code. It contains lots of options such as which files should be compiled, which directory to compile them to, which version of JavaScript to emit, etc.

Our tsconfig.json file lets us change how strict the type checker is when checking types. There are a lot of options that we can choose from, but if we just want to enable all of them and have the type system catch as many bugs as possible, all we have to do is enable the strict flag.

The esModuleInteropflag makes it much easier to use code from both the CommonJS and the ES Modules system. This is often necessary when working with packages from NPM. It adds a small bit of code to our JavaScript output but saves a lot of headaches.

You can check out the official docs on all the options 😵‍💫 or just use this starter tsconfig.json to start.

{
"compilerOptions": {
"outDir": "dist", // where to put the compiled JS files
"target": "ES2016" // which level of JS support to target
},
// Recommended: Compiler complains about expressions implicitly typed as 'any'
"noImplicitAny": true,
"include": ["src"], // what folder or files from to compile
"exclude": ["node_modules"], // which files to skip
"strict": true, // enable all strict type-checking options
"esModuleInterop": true // enable compatibility with CommonJS modules
}

Create an index.ts

TypeScript files have a .ts extension which contains the types and executable code. These are the files that produce the .js output.

If you are using React, then you have .tsx files that would produce .jsx files.

  1. Create an src folder and create a index.ts to start as an example.
function add(a: number, b: number): number {
return a + b
}

console.log(add(1, 2))

2. Add the annotations for the number type for parameters and return type.

3. Run our NPM script:

npm run dev

The TypeScript compiler finds and watches for any .ts files to build. The index.ts is found and the compiler builds the output to index.js. The output directory dist is specified by tsconfig.json.

By default, the compiler will create JavaScript files side-by-side with the TypeScript source file that created it. This is not a good idea because you’ll end up mixing your build results with your source code.

4. The created index.js is good ‘ole JavaScript as you remember.

function add(a, b) {
return a + b;
}
console.log(add(1, 2));

Congrats, you are officially a TypeScript engineer.

ESLint

As mentioned earlier, ESLint is a static code analysis tool that parses code and checks it against a list of rules intended to make it more readable, more performant, and hopefully less bug-prone.

When working with TypeScript, it is common to use both TypeScript’s type checking and ESLint together to ensure code quality and maintainability. By combining these tools, you can catch both type-related errors and other common coding issues.

Installation and setup

  1. Run the commands to setup ESLint in a TypeScript project:
npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
  • eslint: ESLint core library
  • @typescript-eslint/parser: a parser that allows ESLint to understand TypeScript code
  • @typescript-eslint/eslint-plugin: plugin with a set of recommended TypeScript rules

2. Create a .eslintrc file:

{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
]
}

3. Create a .eslintignore file in order to prevent ESLint from linting stuff we don’t want it to:

node_modules
dist

4. Add NPM linting script:

{
"scripts": {
...
"lint": "eslint . --ext .ts",
}
}

Adding Type Annotations

TypeScript automatically reads the code and determines the types for us so we don’t have to annotate all of our types.

However, there are times when TypeScript isn’t able to infer our types. This is often because we are defining a variable before assigning a value to it.

We need to add type annotations to tell TypeScript what type a variable should hold. Adding explicit types can help us avoid using values in a way that would cause errors at runtime.

let favoriteDessert: string;
favoriteDessert = 6; // Type Error: Type 'number' is not assignable to type 'string'.
favoriteDessert = "Cheesecake";

Notice that the types are lowercase. This is the case for string, number, boolean, and most other primitive types. JavaScript has built-in objects for all of its types that use capital cases, including String, Number, Boolean, and Object.

We can assign types to variables for other types.

let numberOfGuests: number;
let menuPlanned: boolean;

Notice that we haven’t assigned any value to these variables. In JavaScript, these variables have the value of undefined. In TypeScript, adding a type definition tells TypeScript that our variable must be that type.

Array types are defined using the square bracket ([]) syntax. There is another syntax for defining arrays with angle brackets (<>), but its use is discouraged.

let ingredients: string[]; // use this syntax
let recipes: Array<string>; // don't use this syntax

Object types can be defined by placing a colon after the property names.

let menu: {
courses: number;
veganOption: boolean;
drinkChoices: string[];
};

Make sure you don’t mistake these types for using object destructuring to assign properties to new variable names. They may look similar, but in one case we’re creating a variable and in the other case we’re adding type annotations.

// These are renamed variables, not types.
let { courses: orderedFood, veganOption: hasVegan } = menu;

Typing Function Declarations

Functions have a lot more moving parts than variables and objects, such as parameters and return values. We can add type annotations to any of these things.

Adding type annotations to function parameters is very similar to adding types to variables.

function alternateUppercase(name: string, index: number) {
if (index % 2 === 0) {
return name.toUpperCase();
}
return name;
}

Now TypeScript knows that our function takes a string and a number as parameters.

Arrow functions work exactly the same way: just put the type after the parameter. This does require that single parameters are wrapped in parentheses.

const doubleNumber = (num: number) => {
return num * 2;
};

One thing that might be surprising is destructuring of function parameters. You might assume that we can just put our type definitions directly on the values we destructure, like so.

// THIS WILL NOT WORK
function getFruitName({ name: string }) {
return name;
}

The problem with this has to do with destructuring syntax.

Putting something after a colon in the destructuring list actually changes the name of the variable from the property name to whatever you put after the colon.

So in this case, we’re putting the name property into a variable called string, which is obviously not what we wanted to do.

Instead, we have to add our type definition to the whole object:

// THIS WILL WORK
function getFruitName({ name }: { name: string }) {
return name;
}

Also, if we are passing an object with many properties to our function, we only need to annotate the object properties that are used in the function.

const fruit = {
name: "Apple",
color: "red",
sweetness: 80,
};

function getFruitName({ name }: { name: string }) {
return name;
}

const name = getFruitName(fruit);

Even though our parameter is expecting an object with only the name property, we can still pass it an object with more properties, so long as it includes name.

Return Values

We can also add types for the value that a function returns.

function headsOrTails(): boolean {
return Math.random() > 0.5;
}

This function most definitely returns a boolean value.

Most of the time TypeScript will infer the return type by what is returned, but adding an explicit return type makes it so we can’t accidentally return a value of the wrong type.

For arrow functions, the type is placed before the arrow.

const headsOrTails = (): boolean => {
return Math.random() > 0.5;
};

Async Functions

By definition, an async function is a function that returns a JavaScript Promise. Just like arrays, there is a special syntax for defining the type of the value which is wrapped in a promise. We place the wrapped type in angle brackets and put Promise in front of it.

async function getFruitList(): Promise<string[]> {
const response = await fetch("https://example.com/fruit");
const fruitList: string[] = await response.json();
return fruitList;
}

If we were to try annotating this function with just string[], TypeScript would warn us that we need to use the Promise type wrapper.

Function Type Expressions

What do we do when we have a function that takes another function (often called a callback) as a parameter? For example, if we were to create a type definition for an array map function, we would have to pass a callback function as one of the parameters.

function mapNumberToNumber(list: number[], callback) {
// (parameter) callback: any
// Implementation goes here
}

Our callback parameter has an any type (by deafult), which means we could call it a function if we wanted to. However, we want to try to avoid any, since using it leads to less type safety.

We can create a function type annotation using a special syntax. It might look like an arrow function, but it’s defining a type.

function mapNumberToNumber(
list: number[],
callback: (item: number) => number,
) {
// (parameter) callback: (item: number) => number
// Implementation goes here
}

Then, when we call the function, TypeScript can check the callback that we pass in to make sure it matches the type signature we used.

const doubledNumbers = mapNumberToNumber(
[1, 2, 3],
(num) => num * 2,
);

In this case, num is inferred to be a number because TypeScript is able to determine the type from the type annotation we added to the callback parameter.

Optional and Default Parameters

TypeScript expects that every parameter of a function will be passed to it when the function is called, even if its value is undefined. This can be a problem when we don't want to require the user to pass in every single parameter every time.

function logOutput(message: string, yell: boolean) {
if (yell) {
console.log(message.toUpperCase());
return;
}
console.log(message);
}

logOutput("Hey! Listen!");
// TypeError: Expected 2 arguments, but got 1.
// An argument for 'yell' was not provided.

We can tell TypeScript that a parameter is optional by adding a ? right before the type annotation.

function logOutput(message: string, yell?: boolean) {
if (yell) {
console.log(message.toUpperCase());
return;
}
console.log(message);
}

logOutput("Hey! Listen!"); // "Hey! Listen!"

We didn’t need to include the second yell parameter because we marked it as optional.

We can also mark parameters as optional by giving them a default value. TypeScript will infer the type of the parameter from the default value.

function logOutput(message: string, yell = true) {
if (yell) {
console.log(message.toUpperCase());
return;
}
console.log(message.toUpperCase());
}

logOutput("Hey! Listen!"); // "HEY! LISTEN!"

void and never Types

void represents the absence of any type. It's often used to indicate that a function doesn't return anything.

function sendRequestAndForget() {
fetch("https://example.com/addUser", { method: "POST" });
}

const output = sendRequestAndForget(); // const output: void;

It’s common to use this type when annotating callback functions that don’t return.

function performRequest(requestCallback: () => void) {
// implementation goes here
}

It’s also a good idea to annotate the return type of your functions with void when you know that the function won't return anything. This will give you a warning if you ever try to return something on accident.

We’ve seen what happens when we have a function that doesn’t return anything. What about functions that can never return anything?

function exception() {
throw new Error("Something terrible has happened");
}

const output = exception(); // const output: void;

function loopForever() {
while (true) {}
}

TypeScript has inferred the type of these functions as void. However, that's not totally accurate.

Our first function will never return because of the throw statement, and the second has an infinite loop. TypeScript gives us a type that lets us be more specific about this function's return type.

function exception(): never {
throw new Error("Something terrible has happened");
}

const output = exception();
// const output: never;

function loopForever(): never {
while (true) {}
}

const loopOutput = loopForever();
// const loopOutput: never;

Instead of representing the absence of a type like void, never represents a value that can never occur. If we ever try to access or modify a never variable, TypeScript will give us a warning.

any and unknown

There are plenty of times when writing TypeScript that we have no way of knowing what the type of something is. Other times, the type signature might be so complicated that it’s easier for us to decrease the type safety and pretend like we are writing JavaScript.

TypeScript gives us two special types that represent values that could be any type: any and unknown.

any

The any type turns off the type checker and removes all guarantees that our types are correct. any can represent any type. It can also be assigned to variables of any type and you can try to access any property on it as if it were an object

One place any is used with network calls, since you can't know what type the network call will return without adding an annotation.

async function getFruitList() {
const response = await fetch("https://example.com/fruit");
const fruitList: any = await response.json();
}

Since we can assign any values to variables with any type, we can add a type annotation to the variable we're putting our results into and TypeScript will start type checking the value.

async function getFruitList() {
const response = await fetch("https://example.com/fruit");
let fruitList: string[];
fruitList = await response.json(); // const fruitList: string[];
}

We have to be really careful any time we use any. If this network request returns anything other than a string[], we might run into runtime type errors.

unknown

unknown is the type-safe version of any.

It still can represent any type, but since we don't know what type it is specifically, TypeScript limits what we are able to do with values of this type. You can assign values of any type to a variable with the unknown type, but you can't assign unknown values to variables of other types.

We also can't access properties on unknown values. Really, you can't do anything with unknown except pass it around.

const unknownString: unknown = "What am I?";
let stringValue: string = unknownString; // Type Error: Type 'unknown' is not assignable to type 'string'.

const unknownNumber: unknown = 27;
let theAnswer = 15 + unknownNumber; // Type Error: Operator '+' cannot be applied to types 'number' and 'unknown'.

How is this even helpful?

It might give us some type of guarantee, but it also means we can’t do anything with our values. Fortunately, we can convince TypeScript that an unknown or any value actually has a more specific type by using a process called type narrowing.

This involves doing runtime checks which either prove that a value is a specific type or prove that it is not a specific type.

In this case, we’ll use JavaScript typeof operator to prove that our value is a number.

const unknownNumber: unknown = 27;

let theAnswer: number = 0;
if (typeof unknownNumber === "number") {
theAnswer = 15 + unknownNumber;
}

TypeScript understands that within that if statement, our unknownNumber the variable is actually a number.

any or unknown?

When working with any kind of dynamic data, such as user input or API responses, you’ll have some degree of uncertainty with the types of data you use. That means at some point you’ll have to use any or unknown, but how do you know when to use one over the other?

any gives you the greatest flexibility, but absolutely no type safety, so it's best to avoid any unless it is absolutely necessary.

Using unknown doesn't give you much flexibility, but it does maintain TypeScript's type safety guarantee and encourages you as the developer to add the necessary checks to narrow your value's type to something more useful.

Interfaces

With objects, we can add type definitions for objects directly to the variable when we’re assigning it, like so:

const car: { wheels: number; color: string; electric: boolean } = {
wheels: 4,
color: "white",
electric: true,
};

This defines the shape of the object and tells us that our object has a number of wheels, a color, and is electric. This works well for one-off objects, but what if we have many objects that all have the same shape? We’ll end up having lots of duplication.

Fortunately, we can construct a special type definition for our object using Interfaces.

Interfaces let us create a named definition of the shape of an object that we can reuse. Once we’ve defined our interface type, we can use it as a type annotation.

interface Vehicle {
wheels: number;
color: string;
electric: boolean;
}

const car: Vehicle = { wheels: 4, color: "white", electric: true };

const motorcycle: Vehicle = {
wheels: 2,
color: "red",
electric: false,
};

const tractorTrailer: Vehicle = {
wheels: 18,
color: "blue",
electric: false,
};

This also helps us tell the difference between two different objects that have a similar shape.

interface Fruit {
name: string;
color: string;
calories: number;
nutrients: Nutrient[];
}

interface Vegetable {
name: string;
color: string;
calories: number;
nutrients: Nutrient[];
}

let apple: Fruit;
let squash: Vegetable;

Just based on the types, we can see that an apple is a Fruit and squash is a Vegetable.

We should recognize though, that because Fruit and Vegetable have the same shape, they are essentially equivalent. We could assign a Fruit variable to a Vegetable variable and TypeScript wouldn't complain. This is because Interfaces don't create new types; they just assign names to a particular shape of types.

This also means that a literal object with the same shape as a Fruit or Vegetable is also compatible.

let fruitBasket: Fruit[] = [];

const tomato = {
name: "Tomato",
color: "red",
calories: 10,
nutrients: [],
};
fruitBasket.push(tomato); // It works.

Unlike classes, an interface is a virtual structure that only exists within the context of TypeScript. The TypeScript compiler uses interfaces solely for type-checking purposes. Once your code is transpiled to its target language, it will be stripped from its interfaces

Extending Interfaces

We can see that our Fruit and Vegetable Interfaces are very similar — we might as well just have one Interface and use it for both of them.

But what if there were properties that were unique to either Fruit or Vegetable?

To handle this, we could extend our interface. This copies the property definitions of one interface to another, which lets you reuse type definitions even more.

interface EdibleThing {
name: string;
color: string;
}

interface Fruit extends EdibleThing {
sweetness: number;
}

const apple: Fruit = { name: "apple", color: "red", sweetness: 80 };

Declaration Merging

Interfaces can be declared multiple times with different properties each time. When TypeScript compiles your code, it will combine the two interfaces together, allowing you to use properties from both of them.

interface Fruit {
name: string;
}
interface Fruit {
color: string;
sweetness: number;
}

const apple: Fruit = { name: "apple", color: "red", sweetness: 80 };

Object Literal Assignment

When TypeScript checks if a value can be assigned to an interface, it verifies the type compatibility of each property. However, when assigning one variable to another, TypeScript only checks the properties defined by the interface, ignoring any additional properties on the variable.

This allows for adding extra properties to a variable that are not defined by the interface. Although accessing these additional properties would result in a type error, they still exist within the variable’s structure.

Optional Properties

Sometimes properties on an interface are entirely optional. Or we will assign them a value later but don’t have that value right now.

We can specify properties of interfaces as optional using a question mark.

interface Fruit {
name: string;
color: string;
calories?: number;
}

In this case, we might not have the calorie count for our fruit before initially defining it, so we mark it as optional. When we create a Fruit object, we don't have to assign the calories property.

Enum and Tuple

TypeScript gives us two types which expand on object and array: Enums and Tuples. The purpose of both of these types is to add even more structure to our types.

Enums

One common programming tip is to avoid using magic values in our code.

function seasonsGreetings(season: string) {
if (season === "winter") return "⛄️";
if (season === "spring") return "🐰";
if (season === "summer") return "🏖";
if (season === "autumn") return "🍂";
}

In this case, the string values winter, spring, summer, and autumn are magic strings. Having them in just one function is fine, but if we were to reuse them over and over in our codebase, there's a chance we might have a typo or miss one of the options.

A magic string is a hard-coded value that is seemingly an arbitrary literal value with no explanation as to what the value means that determines how your program works. It is considered an “anti-pattern”.

An object could be used to put the magic strings in one place and give them a name but it doesn’t solve all the problems it could cause. We can use an Enum to create a type-safe definition of named constants which we can reference elsewhere in our code.

enum Seasons {
winter,
spring,
summer,
autumn,
}

function seasonsGreetings(season: Seasons) {
if ((season = Seasons.winter)) return "⛄️";
// ...
}

const greeting = seasonsGreetings(Seasons.winter);

Notice that we are able to use Seasons as both a type and a value.

We tell TypeScript that the season parameter of our seasonsGreetings function is a Seasons type, which means it has to be one of the constant properties we defined in our Enum.

Then, when we call our function, instead of passing a string, we pass one of the properties of Seasons into the function.

Our Enum acts like an object, where the strings we include are the property names and their values are incrementing numbers, starting at 0. Notice that when we assign the Enum as a type for a variable, we can use any of the properties of the Enum as values for that variable.

Most types are removed when TypeScript compiles code to JavaScript. Enums, on the other hand, are translated into JavaScript snippets that represent their shape and behavior. That makes Enums both a type and a value.

If I were to run the Seasons enum through the TypeScript compiler, this is what it would output:

// compiled .js source:
var Seasons;
(function (Seasons) {
Seasons[Seasons["winter"] = 0] = "winter";
Seasons[Seasons["spring"] = 1] = "spring";
Seasons[Seasons["summer"] = 2] = "summer";
Seasons[Seasons["autumn"] = 3] = "autumn";
})(Seasons || (Seasons = {}));
function seasonsGreetings(season) {
if ((season = Seasons.winter))
return "⛄️";
// ...
}

// {
// '0': 'winter',
// '1': 'spring',
// '2': 'summer',
// '3': 'autumn',
// winter: 0,
// spring: 1,
// summer: 2,
// autumn: 3
// }

This confusing code shows that TypeScript implements enums as an object by using numbers to represent the constants instead of strings. Also, we can see that Enums allow us to access the numbers using the property names but also access the property names with the appropriate number index. 😵‍💫

One thing to remember is that Enums are their own types with unique behavior. You can actually assign numbers to Enum variables so long as Enum doesn't use string values.

Tuple

Most of the time when we use arrays, we intend for it to be variable length, which means we can add and remove items from the array. We also expect all of the elements to be the same type.

The type string[] means an array of any size that can only be strings. What if we had an array where different items were of different types?

Tuples are fixed-length arrays. We can tell TypeScript how many items are in the array, and what the type of each item is.

We write Tuples by wrapping our list of types in square brackets.

const person: [number, string, boolean] = [1, "Frank", true];

TypeScript will never infer an array of items to be a tuple, even if the items are of different types. That means that when you create tuples, you always have to add a type annotation.

‘type’ aliases

type aliases allow us to give names to any other type or combination of types.

It looks very similar to setting a variable, except instead of let or const we use type, and instead of a value, we use a type annotation. Once we've defined our alias, we can use it on variable definitions the same way we use interfaces.

type CarYear = number
type CarType = string
type CarModel = string

type Car = {
year: CarYear,
type: CarType,
model: CarModel
}

const carYear: CarYear = 2010
const carType: CarType = "Ford"
const carModel: CarModel = "Fusion"

const car: Car = {
year: carYear,
type: carType,
model: carModel
};

Any type or type combination can be used with a type alias.

type FruitList = string[];

const fruit: FruitList = ["Apple", "Orange"];

Writing type aliases doesn't create a new type; any value that is compatible with the alias' type will be compatible with the alias.

type FruitList = string[];
interface IndexedFruitList {
[index: number]: string;
}

const fruit: FruitList = ["Apple", "Orange"];
const otherFruitList: string[] = fruit; // This works
const indexedFruitList: IndexedFruitList = fruit; // This also works

Interface or type?

Interfaces and type aliases are very similar. You might even be wondering why TypeScript has two constructs which perform basically the same function. They have subtle differences which can make the difference when deciding whether to use one over the other.

Interfaces support extension using the extends keyword, which allows an Interface to adopt all of the properties of another Interface.

Interfaces are most useful when you have hierarchies of type annotations, with one extending from another.

type aliases, on the other hand, can represent any type, including functions and Interfaces.

interface Fruit {
name: string;
color: string;
sweetness: number;
}

type FruitType = Fruit;

type EatFruitCallback = (fruit: Fruit) => void;

When you find yourself writing the same type signature repeatedly, or if you want to give a name to a particular type signature, you want to use a type alias.

Union Types

Union types are an example of a situation when it might be useful to give an explicit type annotation for a variable even though it has an initial value.

let thinker: string | null = null;

if (Math.random() > 0.5) {
thinker = "Susanne Langer"; // Ok
}

In this example, thinker starts off null but is known to potentially contain a string instead. Giving it an explicit string|null type annotation means TypeScript will allow it to be assigned values of type string.

Union type declarations can be placed anywhere you might declare a type with a type annotation. When a value is known to be a union type, TypeScript will only allow you to access member properties that exist on all possible types in the union. It will give you a type-checking error if you try to access a member property that doesn’t exist on all possible types.

Literal Types

Literal types represent exact values of JavaScript primitives. For example, strings can represent any string, but type literalString = "thisString" can only represent a string with the value of "thisString".

let fruitName: "Apple" = "Apple";
fruitName = "Banana"; // Type Error: Type '"Banana"' is not assignable to type '"Apple"'.

This behavior is inferred automatically when we use const to declare our variables, since that variable will never change to something different.

const fruitName = "Apple";
// const fruitName: "Apple"

A variable that can only be assigned one value isn’t very interesting. Literal types become much more powerful when we combine them with Union types.

Using a Union of literal types allows us to have the same type safety as Enums, but without the extra hassle of accessing our values on the Enum itself.

type Seasons = "spring" | "summer" | "autumn" | "winter";

function seasonMessage(season: Seasons) {
if (season === "summer") {
return "The best season.";
}
return "It's alright, I guess.";
}

seasonMessage("autumn"); // It's alright, I guess.
seasonMessage("fall"); // Type Error: Argument of type '"fall"' is not assignable to parameter of type 'Seasons'.

Common Type Guards

What do you do when you have an unknown type and you want to use it in a meaningful way? What about a Union of several types?

To do so safely requires us adding some runtime checks which prove to the TypeScript type checker that the value has a specific type. We call these runtime checks "type guards".

We can create type guards using any kind of conditional — if statements, switch statements, ternaries, and a few others. We put some kind of check against the value's type inside the if statement's condition. Then, inside the block, our value now has the type we matched it against.

Primitive Types

The most straightforward check is strict equality.

function sayAlexsNameLoud(name: unknown) {
if (name === "Alex") {
// name is now definitely "Alex"
console.log(`Hey, ${name.toUpperCase()}`); // "Hey, ALEX"
}
}

We could pass literally any value to this function because of the type guard. It’s still type safe, since TypeScript knows that inside the if block, name is a literal "Alex" type, which has the same methods and properties as a string.

That’s a little tedious if we needed to do that for literally every name. You can use the typeof operator to check what primitive type a value is. We can do that with any primitive type to narrow down a value to a particular type.

function sayNameLoud(name: unknown) {
if (typeof name === "string") {
// name is now definitely a string
console.log(`Hey, ${name.toUpperCase()}`);
}
}

We could also do the inverse of this. Instead of checking to see if name is a string, we can check if it is not a string, and then return early or throw an error. Then we know that everywhere else in our function, name is a string

function sayNameLoud(name: unknown) {
if (typeof name !== "string") return;
// name is now definitely a string
console.log(`Hey, ${name.toUpperCase()}`);
}

We could use switch statements to check the type as well. In this case, we'll narrow a Union type of number and string, but we could do the same thing with unknown.

function calculateScore(score: number | string) {
switch (typeof score) {
case "string":
return parseInt(score) + 10;
break;
case "number":
return score + 10;
break;
default:
throw new Error("Invalid type for score");
}
}

typeof can cover us in a lot of cases, but once we get to referential types, like objects, arrays, and class instance, we can't use it anymore. Whenever we use typeof of any of these types, it will always return "object".

Arrays

We can check if a value is an array using the Array.isArray method.

function combineList(list: unknown): any {
if (Array.isArray(list)) {
list; // (parameter) list: any[]
}
}

Our list is now a list of anys, which is better than before, but we still don't know the type of the values inside the list. To do that, we'll have to loop through our array to narrow the type of each item. We can use the .filter() and .map() methods on our array to do that.

function combineList(list: unknown): any {
if (Array.isArray(list)) {
// This will filter any items which are not numbers.
const filteredList: number[] = list.filter((item) => {
if (typeof item !== "number") return false;
return true;
});

// This will transform any items into numbers, and turn `NaN`s into 0
const mappedList: number[] = list.map((item) => {
const numberValue = parseFloat(item);
if (isNaN(numberValue)) return 0;
return numberValue;
});

// This does the same thing as the filter, but with a for loop
let loopedList: number[] = [];
for (let item of list) {
if (typeof item == "number") {
loopedList.push(item);
}
}
}
}

Objects

Objects are a little trickier to narrow, since they could have any combination of properties and types. Most of the time we don’t care about the type of the entire object; all we want is to access one or two properties on the object.

We can use the in operator to determine whether a property exists on an object. We can then use the typeof operator to determine that property's type.

The in operator only works to narrow union types, so we can't use it with unknown. Instead, we'll have to use another special type that comes with TypeScript: object. This type represents anything that isn't a string, number, boolean, or one of the other primitive types.

Using object instead of unknown will tell TypeScript to let us attempt to access properties on this value. We can create a Union of the generic object type and an Interface with the property that we want to access.

interface Person {
name: string;
}

function sayNameLoud(person: object | Person) {
if ("name" in person) {
console.log(`Hey, ${person.name.toUpperCase()}`);
}
}

In this case, TypeScript doesn’t care what other properties are on our person type. All that matters is that it has a name property which is a string.

Nullish Coalescing

When you have the option, using a default value for variables that might be undefined is really helpful. Typically we use the logical OR operator (||) to assign the default value.

const messageInputValue =
document.getQuerySelector("messageInput")?.value || "Tom";

messageInputValue; // const messageInputValue: string;

The only problem with this approach is it checks against falsy values, not just null and undefined. That means when our messageInput field is empty, it will still give us the default value since empty string is falsy.

The Nullish Coalescing operator solves this by only checking if a value is null or undefined. It works the same way as the logical OR, but with two question marks (??) instead of two vertical bars.

onst messageInputValue =
document.getQuerySelector("messageInput")?.value ?? "Tom";
messageInputValue; // const messageInputValue: string;

Generics

Generics are one of the most powerful parts of TypeScript.

They make it possible to reuse and transform our types into different types, instead of having to rewrite different definitions for each type. Think of them as functions, but for types. A type goes in, a different type comes out.

Generic Functions

function getFirstItem(list: number[]): number {
return list[0];
}

What if we wanted to grab a string from a list of strings instead? We would have to write another function.

function getFirstStringItem(list: string[]): string {
return list[0];
}

Our array implementation is exactly the same, but we have to write it twice because the type signatures are different. What if we were to use a union type?

function getFirstItem(list: (number | string)[]): number | string {
return list[0];
}

This is better — we only have to define our function once — but it doesn’t really describe our API at all.

This type signature implies that our array contains a mixture of numbers and strings, which isn’t the case at all. Also, once we have the result of the function, we have to narrow the type to be either number or string.

This is where generics come in.

We want the ability to write a type signature for a function that takes in an array of some type, lets call that type ItemType, and returns a single item with the type ItemType. Here's how we would write that signature.

function getFirstItem<ItemType>(list: ItemType[]): ItemType {
return list[0];
}

Notice the angle brackets we use right after the function name. This is where we define generic parameter. Here, we’re defining a generic parameter called ItemType, but we could call it anything we want.

Once we've defined our generic type, we can use it anywhere in our function, including on the parameters and the return signature. Here, we're saying that our list parameter is an array of ItemType, and it returns a ItemType value.

Now we can use it anywhere we want, with any type we want! The best part is TypeScript will automatically infer the return type from the usage. It even correctly infers complex types, like classes.

class Fruit {
constructor(public name: string) {}
}

const fruit = getFirstItem([
new Fruit("banana"),
new Fruit("apple"),
new Fruit("pear"),
]);

fruit; // const fruit: Fruit

So, to recap: Generics represent a type that won’t be defined until the type is used in our code.

We can use generics with functions. When we originally write the generic function, we might not know the type the generics represent, but when we use our function elsewhere in our code, the generics’ types can be inferred from the usage.

This makes it possible to write functions that accept different kinds of types but have the same implementation for each type.

We used ItemType, since our generic parameter represents the type of each item in the array. However, many libraries and authors use single-letter type names, like T or U, which can make generics seem more intimidating.

Generic Types

Generics aren’t just for functions. In fact, we can create generic Interfaces, Classes, and type aliases.

This type represents a tree of string values:

type StringTree = {
value: string;
left?: StringTree;
right?: StringTree;
};

This is a great type which could be really handy when working with trees of strings. But what if we have a tree of something other than astring?

Either you would have to make a separate type for each of them, or I could make a generic type.

type Tree<T> = {
value: T;
left?: Tree<T>;
right?: Tree<T>;
};

You can see that we accept a generic type that we call T. We then use that as the type of the value on our tree.

When we create our left and right properties, we use the same type recursively, but we need to tell that type what generic type it should use. We can just pass T back into our recursive type, which will let us use T for all of the values of our Tree.

We pass types to our generics by putting angle brackets after the generic's name, like so:

type StringTree = Tree<string>;

Let’s see how this looks when actually creating an object with this type.

interface Fruit {
name: string;
color: string;
}

const graftedFruitTree: Tree<Fruit> = {
value: { name: "trunk", color: "brown" },
left: {
value: { name: "apple", color: "red" },
right: {
value: { name: "orange", color: "orange" },
},
},
right: {
value: { name: "pear", color: "yellow" },
},
};

Modules

Sometimes we define a type in one file that we need to reference in another file. We can export and import them like any other value, even alongside normal values.

// ./fruitBasket.ts
export class Fruit {}
export type FruitBasket = Fruit[];

export const fruit: FruitBasket = [];

We can then import and use them in other files:

// ./main.ts
import { FruitBasket, Fruit } from "./fruitBasket.ts";

export function addToBasket(basket: FruitBasket) {
basket.push(new Fruit());
}

Remember, type definitions are removed entirely from our code when we compile TypeScript to JavaScript. These types aren’t actually being imported at runtime; they only exist before the code is compiled.

Sometimes we need to import a type, but we don’t want to execute any code from the file we are importing them from. One solution would be to move the types out into a separate file.

Another solution is indicating to TypeScript that the only things we are importing from a file are types. We can do that with the type keyword.

// ./main.ts
import type { FruitBasket, Fruit } from "./fruitBasket.ts";

This indicates that FruitBasket and Fruit only represent types, not values. Classes are an interesting case; they represent both types and values. If we were to try to instantiate Fruit after only importing the type, TypeScript would warn us that we can't do that.

// ./main.ts
import type { FruitBasket, Fruit } from "./fruitBasket.ts";

new Fruit(); // Type Error: 'Fruit' cannot be used as a value because it was imported using 'import type'.

ES Modules vs CommonJS

TypeScript is a compiler in addition to being a type checker. That means you can feed it any valid JavaScript code and it can transform it into other JavaScript code.

One place where this becomes important is in the module system. There are significant differences between CommonJS, module loaders for AMD modules, and ES Modules, and those differences are most obvious when we have the esModuleInterop tsconfig.json flag disabled.

TypeScript has it’s own module syntax which is unique to TypeScript. It’s designed to model the traditional CommonJS workflow, while being compatible with ES Module syntax.

Exporting modules is similar to CommonJS, except instead of using the special module.exports object, we assign our exported values to a special export object.

// fruitBasket.js
export = new FruitBasket()

We can then import it using a special import ... = require(...) syntax. It looks kind of like CommonJS mixed with ES Modules.

// main.js
import FruitBasket = require('./fruitBasket.js')

console.log(FruitBasket) // [Function: FruitBasket]

Of course, TypeScript helps us with this incompatibility when we turn on the esModuleInterop tsconfig.json flag. Be aware that the export = and import = syntax is TypeScript specific.

Additional TSConfig.json options

TypeScript has over 100 different configuration options which can be used to fine-tune how the type checker and compiler work.

Here is a list of options that could be the most helpful for a TypeScript project, organized by what they are used for.

Inlcuding or excluding a certain file

The files property is a list of paths, relative to the tsconfig.json file, to all of the files in your project. If your project is small and doesn’t necessarily include any dependencies, using the files can make TypeScript speedier since it doesn't have to comb your filesystem for the files that are present in your project.

{
"compilerOptions": {},
"files": ["fruitBasket.ts", "fruit/apple.ts", "fruit/banana.ts"]
}

Typical projects have a few directories, some of which have source code that needs to be checked and some which have support files that don’t need to be checked. By default, TypeScript will compile all .ts and .tsx files in the same directory as the tsconfig.json file. We can selectively include the files that we need using the include.

Both include and exclude use a wildcard character system when evaluating the glob patterns you use in the file paths. **/ matches any directory nested at any level, * matches 0 or more characters in a file or directory name, and ? matches exactly one character in a file or directory name.

{
"compilerOptions": {},
"include": ["src/**/*"]
}

Improving the developer experience

When your TypeScript project starts getting big, your build times might increase substantially. TypeScript provides a few options to make your project build faster and use less memory in these circumstances.

Usually when TypeScript compiles or type checks your code, it does it all at once. Changing even a small part of your project would still require TypeScript to at least look at other files that are imported and exported, and eventually every file has been checked anyway.

When incremental is turned on, TypeScript will keep a cache of the compilation results for itself when it runs a full compile. On subsequent compiles, it will use this cache to skip parts that don't need to be compiled again, making the overall build faster.

{
"compilerOptions": {
"incremental": true
}
}

Source map

Source maps are generated files which provide a map from compiled output, such as the .js files which TypeScript generates, back to the original .ts source, without having to include the source files themselves.

They can be included in build output and shipped to production environments without affecting the end-user's experience since source maps are only loaded when the user opens the developer tools.

Turning on source maps will increase your build time, but could be helpful when trying to debug your code in a production environment

{
"compilerOptions": {
"sourceMap": true
}
}

checkJS

If your project doesn’t have any .js files, or if you specifically don't want TypeScript to check your .js files, you can turn off the checkJs option. This will still let TypeScript compile your JavaScript files, but it won't give you any warnings or errors if your JavaScript has any type errors.

{
"compilerOptions": {
"checkJs": false
}
}

jsx

If you are working with React or another library that uses JSX, you can configure how TypeScript compiles the JSX syntax. If you are working with React, you’ll want to set this setting to react; otherwise, you can set it to preserve, which will keep the JSX syntax in place without changing it.

You can also use the jsxFactory option to change which function is used to compile JSX Elements. By default, it uses React.createElement, but if we were working with a Preact project, we would want to use preact.h

{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "React.createElement"
}
}

Configuring the Compiler output

This lets you specify where your compiled source will be placed relative to the tsconfig.json file. This includes source maps and declaration files, if applicable. If the directory doesn’t exist, TypeScript will make it before outputting the compiled files.

{
"compilerOptions": {
"outDir": "build"
}
}

noEmit

This option tells the TypeScript compiler to only type check our code, not output any files. This is incredibly useful if we are using TypeScript with a separate build tool, like Webpack, Babel, or Parcel. We can still use TypeScript to type check our files with a single command, perhaps as part of our CI/CD process, but the actual compilation will be handled by the other tools in our toolchain.

TSDocs

One of the nicest things about using TypeScript is the IDE integrations.

The integration lets us hover over values to see their definition, autocomplete object property names, and make sure we’re using our types correctly. In many cases, the type definitions alone are enough to not need the documentation for a library.

We can see the properties and their types, but we don’t know why they are important. Is color the color name, or an RGB hex value? What does sweetness represent? Is it a number from 0 to 1, or from 0 to 100, or from -1 to 1? Those kinds of details aren't communicated through the types.

Fortunately, there are other standards that exist which let us add comments above our function and variable declarations to document them in greater detail. The TSDoc standard is based on the existing JSDoc standard, but is entirely designed to work with TypeScript.

/**
* Represents a user.
*/
type User = {
/**
* The name of the user.
*/
name: string;
/**
* The age of the user.
*/
age: number;
};

/**
* Prints a greeting for the user.
* @param {User} user - The user object.
* @param {string} greeting - The greeting message.
* @returns {string} The formatted greeting.
*/
function greetUser(user: User, greeting: string): string {
return `${greeting}, ${user.name}!`;
}

/**
* Adds two numbers and returns the result.
* @param {number} a - The first number.
* @param {number} b - The second number.
* @returns {number} The sum of the two numbers.
*/
function addNumbers(a: number, b: number): number {
return a + b;
}

By using TSDoc syntax with functions and types, you can provide meaningful documentation for your TypeScript code, making it easier to understand and use for other developers.

Documentation Generation Tool

TSDoc itself does not directly generate documentation. You need to use a documentation generation tool that supports TSDoc. There are several options available, such as TypeDoc, API Extractor, and others.

In this example, we’ll focus on TypeDoc.

npm install typedoc --save-dev
  1. After installing TypeDoc you can create a configuration file as atypedoc.json file.
{
"$schema": "https://typedoc.org/schema.json",
"entryPoints": ["./src/"],
"out": "doc",
"exclude": ["**/node_modules/**/*.*"],
}

2. You can create an docs npm script in your package.json:

"scripts": {
"dev": "tsc --watch --preserveWatchOutput",
"lint": "eslint . --ext .ts",
"docs": "typedoc"
},

3. A doc folder will be created in the root of your project:

4. You can view your generated docs in your browser:

Unexpected TypeScript Behavior

There are many ways that TypeScript tries to make sure your code is correct and free of type errors. All of these behaviors have reasonable explanations, but they might not be entirely obvious at first.

Structural Typing

TypeScript uses a structural typing system, also known as duck typing. This means it checks to see if the properties of types match, rather than identifying two types as being different if they were defined in different places.

interface Apple {
name: string;
color: string;
}
interface Banana {
name: string;
}
const fruit: Apple = { name: "Banana", color: "Yellow" };
const otherFruit: Banana = fruit;

Even though otherFruit is a Banana type, we can still assign an Apple value to it, since Apple has the same properties as Banana.

Apple even has an extra property, but TypeScript doesn't mind. So long as it has the properties it needs, it will ignore any extra properties we assign.

Type Erasure

The second thing which has the biggest impact on strange behaviors is type erasure. This means that when our code is compiled from TypeScript to JavaScript, all of the type information is removed from our code. This includes interfaces, type aliases, type signatures, and generics.

There is a distinction between types and values. Types describe the structure of a value, while the value holds information in the structure the type specifies. So the value of an object sticks around after compilation, but the interface that describes the shape of that object is removed.

// THIS WILL NOT WORK
function eatIfString<T>(fruit: T) {
if (typeof T === "string") {
// Do a thing
}
}

This doesn’t work, because T doesn't have a value. It represents a type. What we can do is check the type of the value which is passed into our function instead.

// THIS WILL WORK
function eatIfString<T>(fruit: T) {
if (typeof fruit === "string") {
// Do a thing
}
}

What’s Next?

The flood of information on TypeScript, and the amount of opinions on TypeScript, can be overwhelming. TypeScript is an evolving language, and the new features can sound so obtuse unless you have a firm understanding of the foundations and the design constraints on the language.

If you are a React developer, I recommend the React with TypeScript course by Matt Pocock.

Practice with existing or new projects and slowly grow your TypeScript vocabulary and level-up your experience. 🏆

--

--

Frank Stepanski
Frank Stepanski

Written by Frank Stepanski

Engineer, instructor, mentor, amateur photographer, curious traveler, timid runner, and occasional race car driver.

No responses yet