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 await
ing 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 any
s, 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:
Now take the following code of a tweet of Michael Jackson :
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:
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 union
s, intersection
s, tuples
and array
s 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
- TypeScript Handbook
- TypeScript Deep Dive
Effective TypeScript
by Dan Vanderkam