03 · TypeScript for Node¶
Most teams building Node services at scale use TypeScript: JavaScript plus a static
type system that catches mistakes — wrong argument types, misspelled properties, missing
null checks — before the code runs, and makes refactoring large codebases far less
scary. This lesson focuses on the Node-specific side: how TypeScript code actually gets
executed, how to configure it for ES modules, and how to type an Express service without
fighting the framework. For the language itself, see the
TypeScript Mastery Path.
Two jobs: type checking and running¶
TypeScript tooling does two separate things:
- Type checking —
tscanalyzes your code and reports errors. It is the only step that knows about types. - Producing runnable JavaScript — removing type annotations (and, for some syntax, transforming code).
Historically you compiled with tsc to a dist/ folder, or ran code through ts-node,
tsx, or a bundler. Current Node releases can do step 2 themselves: node file.ts
works by stripping type annotations on the fly. (Older releases needed a flag such as
--experimental-strip-types; check your version.) Type checking remains tsc's job.
Running .ts files directly¶
export type Currency = 'USD' | 'EUR' | 'INR';
export interface Money {
readonly amountMinor: number; // integer minor units (cents, paise)
readonly currency: Currency;
}
export function add(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new Error(`cannot add ${a.currency} to ${b.currency}`);
}
return { amountMinor: a.amountMinor + b.amountMinor, currency: a.currency };
}
export function format(m: Money): string {
return new Intl.NumberFormat('en', { style: 'currency', currency: m.currency })
.format(m.amountMinor / 100);
}
import { add, format, type Money } from './money.ts';
const price: Money = { amountMinor: 1999, currency: 'USD' };
const shipping: Money = { amountMinor: 500, currency: 'USD' };
console.log(format(add(price, shipping)));
Note the import: './money.ts' with the .ts extension, because Node resolves the
file exactly as written (ESM rules from Level 1, lesson 03). And type Money is imported
with the type modifier, marking it as erased at runtime.
Only "erasable" syntax¶
Node's type stripping replaces type annotations with whitespace; it doesn't transform code. TypeScript features that generate JavaScript therefore don't work:
$ node src/enum.ts
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
The same applies to namespace blocks with values, constructor parameter properties
(constructor(private db: Db)), and legacy decorators with metadata. Use union types
('active' | 'disabled') or as const objects instead of enums, and explicit class
fields instead of parameter properties. The compiler option erasableSyntaxOnly makes
tsc flag these, so editors warn you before Node does.
Type checking with tsc¶
{
"compilerOptions": {
"target": "es2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src"]
}
module/moduleResolution: "nodenext"— follow Node's real ESM/CJS rules, including"type": "module"andexportsmaps.noEmit—tsconly checks; Node runs the.tsfiles.allowImportingTsExtensions— permit./money.tsimports.verbatimModuleSyntax— requiresimport typefor type-only imports, so what you write is exactly what gets erased.strict— non-negotiable for new code.
Type stripping does not check types. A file with type errors still runs:
import { add, type Money } from './money.ts';
const a: Money = { amountMinor: 100, currency: 'USD' };
add(a, { amountMinor: '5', currency: 'GBP' });
$ npx tsc
src/bad.ts(3,10): error TS2322: Type 'string' is not assignable to type 'number'.
src/bad.ts(3,28): error TS2322: Type '"GBP"' is not assignable to type 'Currency'.
$ node src/bad.ts
Error: cannot add USD to GBP
tsc caught both mistakes; Node happily ran the file until the runtime check threw.
So tsc --noEmit must run in CI (and ideally in a pre-commit hook or your editor).
Add scripts:
If you publish a library or want the smallest possible startup, you can still compile to
JavaScript with tsc (with rewriteRelativeImportExtensions turning .ts imports into
.js) and run the output.
Worked example: typed Express with Zod¶
Types and runtime validation should come from one source. With Zod, the schema is the source and the type is derived:
import express, { type Request, type Response, type NextFunction } from 'express';
import { z } from 'zod';
const CreateProduct = z.object({
name: z.string().trim().min(1).max(120),
priceMinor: z.number().int().nonnegative(),
tags: z.array(z.string()).default([]),
});
type CreateProduct = z.infer<typeof CreateProduct>; // the static type, derived from the schema
interface Product extends CreateProduct { id: number }
const products = new Map<number, Product>();
let nextId = 1;
// Typed middleware that validates the body and passes parsed data via res.locals
function validateBody<S extends z.ZodType>(schema: S) {
return (req: Request, res: Response<unknown, { body: z.infer<S> }>, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({ error: 'validation failed', issues: result.error.issues });
return;
}
res.locals.body = result.data;
next();
};
}
export const app = express();
app.use(express.json());
app.post('/products', validateBody(CreateProduct), (req, res: Response<Product, { body: CreateProduct }>) => {
const product: Product = { id: nextId++, ...res.locals.body };
products.set(product.id, product);
res.status(201).json(product);
});
app.get('/products/:id', (req: Request<{ id: string }>, res: Response<Product | { error: string }>) => {
const product = products.get(Number(req.params.id));
if (!product) {
res.status(404).json({ error: 'not found' });
return;
}
res.json(product);
});
$ npx tsc && node src/server.ts
{ id: 1, name: 'Desk lamp', priceMinor: 2599, tags: [] }
{ id: 1, name: 'Desk lamp', priceMinor: 2599, tags: [] }
(tsc exited with no errors; the run used Express 5 with @types/express.) Points to
notice:
z.infer<typeof CreateProduct>gives the exact type of the parsed value, including the default fortags. Change the schema and every use of the type updates.Request<{ id: string }>typesreq.params; route params are strings, and the type says so.Response<Body, Locals>types whatres.jsonaccepts and whatres.localsholds, so the validated body flows to the handler with a real type.- Request data is
unknownuntil validated. Resistreq.body as CreateProduct— a type assertion is a promise to the compiler that nothing checks at runtime.
How It Actually Works¶
Node's TypeScript support is built on Amaro, a small wrapper around the SWC parser
compiled to WebAssembly. When Node loads a .ts/.mts/.cts file, it parses it and
replaces every type-only construct (annotations, interfaces, type aliases, import type)
with whitespace of the same length. Because positions don't move, line and column
numbers in stack traces match your source without source maps — note how the bad.ts
error pointed at money.ts:10. Files inside node_modules are deliberately not
stripped: packages are expected to ship JavaScript.
tsc works entirely separately: it builds a program from your include files, resolves
imports with the configured module resolution, loads declaration files (@types/node,
@types/express, or types bundled with packages like Zod), and runs the type checker. It
never affects what Node executes when noEmit is set.
Types are erased: at runtime there is no Money interface to check against. That is
exactly why HTTP input still needs Zod (or similar) even in a fully typed codebase.
Common mistakes¶
- Assuming
node file.tstype-checks. It doesn't. Runtscin CI. - Enums, parameter properties, and namespaces in code meant to run via type stripping.
- Importing
./x.jsin source that has./x.tsunder strip mode, or omitting extensions entirely. ascasts on request data instead of validation.anycreep — enablestrictandnoImplicitAny(included instrict) from day one; preferunknownfor values you haven't checked.- Mismatched
@types/*versions — e.g.@types/nodefor a different major than you run. Match them to your runtime.
Exercise¶
- Convert the Level 2
validate.jsanderrors.jsto TypeScript and maketscpass withstrict. TypeHttpErrorsostatusis a union of the codes you actually use. - Replace an
enumin some existing code (or invent one) with anas constobject and a derived union type:type Role = (typeof Roles)[keyof typeof Roles]. - Add
npm run typecheckto a CI workflow and make the build fail on type errors. - Deliberately introduce a type error and confirm the app still starts with
node, then explain to a teammate why CI must runtsc.