Mark Pollmann's blog

Using NodeJS with TypeScript in 2024

Note: This article is aimed at Node developers looking into TypeScript and is planned as a living document. When I find something noteworthy I will add it. If you have something cool to share, send me an email!

What TypeScript promises

Be more confident about your code and catch issues early

You will catch errors right in your editor, instead of in your tests (or production).

For example, one mistake that is quite hard to spot in plain JavaScript is not awaiting a promise in an async function:

const myFunc = async() => {
    try {
        const entity = findEntityOrThrow() // oops, forgot the `await`

        // TS will warn that type Promise<Entity> doesn't have a property called doEntityStuff
        entity.doEntityStuff 
    } catch (error) {
       [...]
    }
}

Self-Documenting Code

What was the order of parameters of function again? Did this function return a promise? Just hover over it in your IDE. It’s like a free JSDoc .

Basically every library is already typed for you

Either it brings its own types (because it’s written in TS or a .d.ts file was added to its package) or you can get them from DefinitelyTyped and just install via npm install -D @types/name-of-the-lib.

Use the latest JavaScript features even before they even land in Node

TypeScript is a superset of JavaScript. Which means if things get added to JS they will be added to TS as well. The TypeScript team adds features that land in the ECMAScript spec stage 3 (not the final step but pretty sure to come) pretty quickly to TS. Features like the nullish coalescing operator were added in less than a month after anouncement.

Faster Onboarding of New Teammates

New developers can understand your codebase quicker (self-documenting code) and are less afraid of checking in faulty code (the static types will give them fewer chances to do so).

Choose your own sweet spot between strictness and agility for your team

Don’t believe the purists: You don’t have to write perfect TS from the start. Allow escape hatches from the type system via explicit or implicit anys, unknown or ignoring specific lines with @ts-ignore comments.

“TypeScript slows us down”

If TS refusing to compile is bringing you down you can decouple type-checking from code generation. Meaning you tell TS to not emit any code (--noEmit = true), just give some helpful advice and let babel convert your TS to JavaScript. This way TS will never refuse to compile and you can check in your code even if it’s not perfect.

It’s Open Source

Not a unique selling point nowadays but still, it’s developed on GitHub , you can just look at the issues in the repo if you encounter an issue or read their roadmap to get a sense of what’s coming.

What does it not do?

TS annotations don’t (and probably never will) change your JavaScript code. At compile time all TypeScript specific annotations are removed and the JS code is emitted as is. It can downcompile your code for earlier environments, though. (see the target compiler option ). This is more interesting in a browser environment but still good to know.

I’m convinced, let me play with it!

You can get a feel for the language (and its generated output) at the TypeScript playground .

Best Practices

Be specific

TS has literal types where you allow only some specific values instead of the more general types like number or string. So if you want to only allow specific values you can do so. Let’s say you have a function that expects only one of three values: ‘running’, ‘stopping’, and ‘stopped’:

// ❌ too generic
function myFunc (mode: string) {...}

// ✅ catches typos like 'runing'
type mode = 'running' | 'stopping' | 'stopped' 

function myFunc (mode: Mode) {...}
// also you might decide to change 'running' to 'isRunning'
// TS will tell you if you forget to update it somewhere in your codebase

Don’t be too explicit

You don’t have to annotate every variable. That actually introduces noise and can hinder type inferring. Try to let the types flow through the codebase.

let isRunning: boolean = false // unnecessary annotation
let isRunning = false // boolean is inferred

let counter: number = 0 // unnecessary annotation
let counter = 0 // number is inferred

const myArray: MyCustomObject[] = ...
// 👇 unnecessary annotation of obj, gets inferred
// If you change the type of myArray later the explicit annotation would be wrong
myArray.map((obj: MyCustomObject) => {...}) 

Note, though, that the inferred types are not the strictest types that are possible (these would be type false for isRunning and type 0 for counter. That would mean you couldn’t toggle isRunning to true or increment counter, though, which is not what we want). This is called type widening .

When to use any vs unknown

unknown is the type-safe alternative to any and should be preferred. If you declare a variable as unknown the compiler doesn’t allow you to do anything with it until you checked its type:

With any you’re free to do any mistake you want:

const myBanana: any = somethingFromAnApiForExample

// TS: Sure, bro, let me just crash if it's not a string 😈
myBanana.toUpperCase() 

// TS: If you think that's a good idea...
myBanana.thisMethodDoesNotExist()

// TS: check your own grammar
myBanana.touppercasee()

With unknown TypeScript has your back:

const myBanana: unknown = somethingFromAnApiForExample

// I'm pretty sure this is a string 
myBanana.toUpperCase() // TypeScript: ❌ "pretty sure" is not good enough

if (typeof myBanana === 'string') {
    myBanana.toUpperCase() // TypeScript ✅ now we know it really is a string

    // error caught in compile-time
    myBanana.thisMethodDoesNotExist()

    // error caught in compile-time
    myBanana.touppercasee()
}

Type Your Promises

When writing a promise try to be as specific as possible regarding the type of the promise.

// ❌ Inferred return type is `Promise<unknown>`
const myApi = () => {
    return new Promise((resolve, reject) => {
        resolve(1)
    })
}

// ✅ Now we know it's a `Promise<number>`
const myApi: () => Promise<number> = () => {
    return new Promise((resolve, reject) => {
        resolve(1)
    })
}

esModuleInterop

You should turn on the esModuleInterop compiler option. If you don’t, you will get problems when you try to default import some libraries that were written before the ES Modules spec. Because this spec does not allow the default export to be callable. An example would be Express :

import express from 'express'
const app = express()

Which results in the the following error:

Module '"[...]/node_modules/@types/express/index"' can only be default-imported using the 'esModuleInterop' flag

index.d.ts(116, 1): This module is declared with using 'export =', and can only be used with a default import when using the 'esModuleInterop' flag.

Don’t fall into traps with Object, object and

If you type a variable with {} it doesn’t mean it’s an object like you know them from JavaScript. It is called the empty type and it represents a value that has no members of its own.

let a: {}

a = {}  // ✅
a.name = 'hi'  // ❌ Property 'name' does not exist on type '{}'.
a = []  // ✅
a = 'hi' // ✅
a = 5  // ✅
a = false  // ✅

Similar with object: It’s not an object as you might expect but represents all non-primitive types (Primitive types are null, undefined, string, boolean, number, bigint and symbol).

let b: object

b = {} 
b = [] 
b = 'hi' 
b = 5 
b = false 

The Object type describes functionality of all JavaScript objects. Like all capitalized general types it is to be avoided, though .

Use Advanced Types

They can look intimidating but most are actually quite straight-forward.

Take a look at the Omit type for example. We take a type or interface and remove properties from it:

Example of Omit type

Now take the following code of a tweet of Michael Jackson :

Tweet about type of React Router

He’s removing the href attribute and adding three more properties to his own type. Looks super-complex but is actually quite straightforward.

Another type is Partial. It takes a type or interface and makes all properties optional:

Example of Partial type

Other examples would be Readonly<T> to make all properties (you guessed it) readonly or Pick<T, K extends keyof T> which lets you pluck some types from another type.

You can read more about advanced types here .

Type or Interface?

Both are pretty interchangable for simple types:

type MyType = {
    firstName: string
    lastName: string
    isAdmin: true
}

interface MyInterface {
    firstName: string
    lastName: string
    isAdmin: true
}

type MyFunc = (x: string) => number

interface MyFunc2 {
    (x: string): number
}

You can also use generics with both and classes can implement both.

I prefer types, though, as you can implement unions, intersections, tuples and arrays easier:

type MyUnion = 'running' | 'stopped'

type MyIntersection = SomeTypeA & SomeTypeB

type MyTuple = [number, number]

type MyArray = string[]

Don’t Prefix your Interfaces with I or your Types with T

You will see some types in the wild like TProps or IRoute. These prefixes are unnecessary and nowadays considered bad style.

Express

No need to type middleware functions

import express, { Request, Response } from 'express'
const app = express()

// ❌ unnecessary annotations
app.get('/', (req: Request, res: Response) => {
    
})
import express from 'express'
const app = express()

// ✅ types Request and Response get inferred
app.get('/', (req, res) => {
    
})

If you can’t inline your middleware you can type the function itself instead of the parameters:


// ❌ Not wrong but lots of noise
import express, { Request, Response, NextFunction } from 'express'
const myMiddleware = (req: Request, res: Response, next: NextFunction) => {}

// ✅
import express, { Handler } from 'express'
const myMiddleware: Handler = (req, res, next) => {}

Further Reading