Appendix A

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 .ts files natively with type stripping, and its rules (erasable syntax, .ts extensions, import type).
  • Type checking with tsc --noEmit and a tsconfig.json suited to Node.
  • The alternatives: transpiling with tsc, and runners like tsx.
  • Publishing a TypeScript package: compiled JavaScript plus .d.ts files.

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 fieldsenum, 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:

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

ApproachHowWhen
Native type strippingnode file.tsnew projects that use erasable syntax; scripts; tests
Transpile with tsctsc β†’ dist/*.js, then node dist/server.jspublishing libraries; projects using enums/decorators; older Node versions
A runner (tsx, ts-node)npx tsx file.tsfull TypeScript syntax without a build step, e.g. existing codebases
A bundler (esbuild, Vite…)build one optimised filefront-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.ts strips types and runs; it does not type-check. Only erasable syntax works: no enums, parameter properties or value namespaces.
  • Import with full .ts extensions, and use import type for types; erasableSyntaxOnly + verbatimModuleSyntax make tsc enforce the rules.
  • Run tsc --noEmit in CI. Use tsx or compilation for full TypeScript syntax.
  • Publish compiled JavaScript + .d.ts files, with a "types" condition in exports.

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
  1. Add typescript and @types/node as devDependencies and the tsconfig.json above (with noEmit). Add "typecheck": "tsc".
  2. 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 .ts extension.
  3. Add types: a Note interface, Config, a Logger type, handler context types for the router; use import type. Replace nothing with enums or parameter properties.
  4. import.meta.main works unchanged. Scripts become node src/server.ts; the CLI's shebang still works with node; "bin" points at src/cli.ts for local use (compile it if you publish).
  5. Rename tests to *.test.ts; node --test finds them. Run npm run typecheck and npm test in 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.

Official docs