TypeScript in Node.js
TypeScript adds static types to JavaScript: you describe the shape of your data, and the compiler catches mistakes before the code runs. Node can now run .ts files directly by stripping the types. This appendix explains what that means, what syntax works, how type checking fits in, and the other ways to run and publish TypeScript.
You will learn
- What TypeScript adds, and the difference between type checking and running.
- Running
.tsfiles natively with type stripping, and its rules (erasable syntax,.tsextensions,import type). - Type checking with
tsc --noEmitand atsconfig.jsonsuited to Node. - The alternatives: transpiling with
tsc, and runners liketsx. - Publishing a TypeScript package: compiled JavaScript plus
.d.tsfiles.
What TypeScript adds
TypeScript is JavaScript plus type annotations: function total(items: Item[]): number. A separate tool, the TypeScript compiler (tsc), reads those annotations and reports errors like "you passed a string where a number is expected" β in your editor as you type, and in CI. The types themselves don't exist at run time: the JavaScript engine never sees them.
π§ Analogy: a spell-checker with a grammar guide. Types are notes in the margin of your manuscript: "this word is a noun", "this sentence needs a verb". The proof-reader (tsc) checks the manuscript against those notes before it's printed. The printing press (Node) ignores the margin notes entirely β it prints the text. Node's type stripping is exactly that: it rubs out the margin notes and prints. It does not proof-read.
β Java: TypeScript's types feel familiar (interfaces, generics, union types), with two big differences: they're structural (any object with the right properties fits an interface, no implements needed), and they're erased β there's no runtime type information, no reflection on generics, and a wrong cast isn't checked at run time.
Running TypeScript natively
Since Node 23.6 (and in all current LTS versions), you can run a .ts file directly. Node removes the type annotations (replacing them with whitespace, so line and column numbers stay correct) and runs the JavaScript that remains:
Two rules are visible here: the import uses the full file name ./user.ts (Node doesn't guess extensions, just like with .js in ES modules), and type User marks an import that exists only for types.
Stripping does not check types
Node ran code that a careful type checker would flag (or that hides behind any and casts). Types describe your intent; only tsc verifies it, and only validation verifies data coming from outside (requests, files, env vars).
What syntax works: erasable only
Type stripping can only delete TypeScript syntax; it can't generate code. So TypeScript features that produce JavaScript at run time aren't supported:
| Works (erasable) | Doesn't work (needs code generation) |
|---|---|
interface, type, annotations, generics, as, satisfies, !, import type, declare, abstract, access modifiers on fields | enum, namespace with values, parameter properties (constructor(private x: number)), legacy decorators with metadata, import x = require() |
Use plain JavaScript instead, which is what modern TypeScript style recommends anyway:
Import types with the type keyword
Because the types are deleted, an import of something that is only a type would point to an export that doesn't exist in the JavaScript. Mark type-only imports with type:
The fix is import type { User } from "./user.ts" (or import { type User, describe }). The verbatimModuleSyntax compiler option makes tsc enforce this for you.
Type checking with tsc
Install TypeScript as a dev dependency and let tsc check (but not emit) your code, in the editor and in CI:
A tsconfig.json that matches Node's native TypeScript rules:
erasableSyntaxOnlyreports enums, parameter properties and other non-erasable syntax as errors.verbatimModuleSyntaxrequiresimport typefor type-only imports.allowImportingTsExtensionslets you write./user.ts;rewriteRelativeImportExtensionsturns those into.jsif you ever compile withtsc.@types/nodegives types for Node's APIs (fs,http,processβ¦).
A typical package.json: "start": "node src/server.ts", "typecheck": "tsc", "test": "node --test" (the test runner finds *.test.ts files too).
Other ways to run TypeScript
| Approach | How | When |
|---|---|---|
| Native type stripping | node file.ts | new projects that use erasable syntax; scripts; tests |
Transpile with tsc | tsc β dist/*.js, then node dist/server.js | publishing libraries; projects using enums/decorators; older Node versions |
A runner (tsx, ts-node) | npx tsx file.ts | full TypeScript syntax without a build step, e.g. existing codebases |
| A bundler (esbuild, Viteβ¦) | build one optimised file | front-end code, serverless functions |
Whatever runs the code, keep tsc --noEmit in CI: runners and type stripping are fast precisely because they skip type checking.
Publishing a TypeScript package
Packages on npm should ship JavaScript (so every consumer can run them, whatever their setup) plus declaration files (.d.ts) that carry the types for TypeScript users. Node doesn't strip types inside node_modules, which is another reason to compile.
The build config sets "declaration": true, "outDir": "dist" and turns noEmit off. prepublishOnly makes sure npm publish never ships stale files.
More examples
Generics and narrowing at run time
Testing TypeScript with node:test
Recap
- TypeScript = JavaScript + types checked by
tsc; types are erased and don't exist at run time. node file.tsstrips types and runs; it does not type-check. Only erasable syntax works: no enums, parameter properties or value namespaces.- Import with full
.tsextensions, and useimport typefor types;erasableSyntaxOnly+verbatimModuleSyntaxmaketscenforce the rules. - Run
tsc --noEmitin CI. Usetsxor compilation for full TypeScript syntax. - Publish compiled JavaScript +
.d.tsfiles, with a"types"condition inexports.
Exercises
Exercise 1 π’ easy run it
Write hello.ts with a typed function greet(name: string, times: number): string that repeats a greeting, and run it with node.
Show solution
Exercise 2 π’ easy predict
Will Node run each line in a .ts file? (a) let id: number = 5; (b) enum Color { Red } (c) type Id = string | number; (d) const x = value as string; (e) class A { constructor(public n: number) {} }
Show solution
(a), (c), (d): yes β erasable. (b) and (e): no β enums and parameter properties need generated code, so Node throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.
Exercise 3 π’ easy fix it
Rewrite enum Level { Low = "low", High = "high" } so it runs under type stripping and still gives you both a value object and a type.
Show solution
Exercise 4 π’ easy explain
A teammate says "Node runs TypeScript now, so we don't need tsc anymore." What do you answer?
Show solution
Node only removes the types; it never checks them. Code with type errors runs (and fails or misbehaves at run time). Keep tsc --noEmit (or the editor's checks) in the workflow and in CI; Node's native support just removes the build step for running.
Exercise 5 π‘ medium modules
Create shapes.ts exporting an interface Shape and a function area(s: Shape) for circles and rectangles (a discriminated union on kind), and import both correctly from use-shapes.ts.
Show solution
Exercise 6 π‘ medium validation
Types don't validate JSON. Write parseUser(json: string): User that checks the parsed data at run time and throws a helpful error, so the User type is actually true.
Show solution
Using unknown (not any) forces you to check before you use. Libraries like Zod generate both the check and the type from one schema.
Exercise 7 π‘ medium tsconfig
Which tsconfig options would have caught (a) constructor(private db: Db), (b) import { Config } from "./config.ts" where Config is only a type, before running the code?
Show solution
(a) "erasableSyntaxOnly": true reports parameter properties (and enums, namespaces) as errors. (b) "verbatimModuleSyntax": true requires import type for type-only imports, preventing the runtime "does not provide an export named" error.
Exercise 8 π‘ medium a typed server
Write a tiny typed HTTP server in server.ts using node:http with a typed route table Record<string, (url: URL) => unknown>, and test it with fetch.
Show solution
Exercise 9 π΄ hard migrate
Plan the migration of the Chapter 16 project to TypeScript: the order of steps, which files change, what to do about import.meta.main and the test files, and how package.json changes.
Show solution
- Add
typescriptand@types/nodeas devDependencies and thetsconfig.jsonabove (withnoEmit). Add"typecheck": "tsc". - Rename files one by one, leaves first:
errors.jsβerrors.ts,config.ts,logger.ts,router.ts,store.ts,app.ts,server.ts,cli.ts; update every relative import to the.tsextension. - Add types: a
Noteinterface,Config, aLoggertype, handler context types for the router; useimport type. Replace nothing with enums or parameter properties. import.meta.mainworks unchanged. Scripts becomenode src/server.ts; the CLI's shebang still works withnode;"bin"points atsrc/cli.tsfor local use (compile it if you publish).- Rename tests to
*.test.ts;node --testfinds them. Runnpm run typecheckandnpm testin CI.
Exercise 10 π΄ hard types at the boundary
Write a generic defineConfig helper in TypeScript that reads environment variables according to a schema object (each entry says the type β "string" | "int" | "bool" β and a default) and returns a correctly typed config object, validated at run time.
Show solution
The mapped type Out<S> computes the config's type from the schema, so config.PORT is a number in the editor β and the run-time checks make sure that's true.