Node + TypeScript in 2026
Expert · 18 min read · ▶ live playground · ✦ checkpoint
Node can run TypeScript now, but that sentence is easy to overread. In 2026, the professional server loop is still two separate jobs: Node or a runner executes erasable TypeScript, and tsc --noEmit checks the project.
This is a tool-shaped server lesson. The terminal commands, config files, and Node examples are read-only reference shapes for a real repository; this page's playground does not run Node APIs, open ports, touch databases, or install packages.
Node strips types; it does not check them
Modern Node can run some .ts files directly by stripping TypeScript syntax that cleanly erases to JavaScript. That is useful, but it is not the TypeScript checker.
node src/server.ts
npm run typecheckRead those commands as two different claims:
node src/server.tsasks, "Can Node strip this file and run the resulting JavaScript?"npm run typecheckasks, "Is the whole TypeScript program type-consistent?"
Those are not interchangeable. A server can start while a type error still exists in a different route file. A file can use TypeScript syntax that needs real JavaScript output rather than simple erasure. Node's built-in support is intentionally run-oriented; the compiler remains the type authority.
erasableSyntaxOnly is the TypeScript-side guardrail for that world. It asks the checker to reject most TypeScript constructs that cannot be erased safely by a strip-only runtime. Pair it with noEmit when you want Node to run the files and TypeScript to police the shape.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true
},
"include": ["src/**/*.ts"]
}Pick the dev loop on purpose
Most server projects still keep small scripts instead of memorizing commands:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"dev:node": "node --watch src/server.ts",
"typecheck": "tsc --noEmit",
"typecheck:watch": "tsc --noEmit --watch"
}
}If the project already uses tsx, tsx watch remains a common development runner when you want its transform conveniences and watch workflow. node --watch is the built-in Node restart loop: Node watches, restarts the process, and still does not type-check.
The honest local setup is often two terminals:
npm run dev
npm run typecheck:watchOne terminal answers runtime feedback: did the server boot, did the route return, did the log show the thing you expected? The other answers type feedback across the project. You can use npm run dev:node instead of npm run dev when your source stays within Node's strip-only TypeScript support. The type-checking terminal stays either way.
This split also keeps CI boring. Section 19 already made tsc --noEmit a required check; server TypeScript does not get an exception just because the runtime learned how to erase types.
Prefer ESM-first Node projects
For new server code, an ESM-first project is usually the least surprising direction: "type": "module" in package.json, import/export in source, and NodeNext in tsconfig.json.
NodeNext tells TypeScript to model Node's current module rules instead of pretending every import is bundled for the browser. That matters for file extensions, package exports, ESM/CommonJS boundaries, and declaration lookup. It is the checker version of a familiar Section 9 rule: module resolution is a contract, not a vibe.
Read-only reference code:
import { createServer } from "node:http";
import { config } from "./config.ts";
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true, mode: config.mode }));
});
server.listen(config.port, () => {
console.log("listening on port " + config.port);
});This is not playground code. It uses Node's node:http module and process-backed config, so it belongs in a real project with Node's type declarations and your server runner.
For published packages or compiled output, Section 18's rule still matters: runtime specifiers must be valid for the files Node will actually load. If your build emits .js, your source may need NodeNext-style .js specifiers. If you run .ts source directly with noEmit, .ts specifiers pair with allowImportingTsExtensions. The important habit is not the suffix itself; it is making TypeScript and Node agree on the same resolution story.
Parse env and config at process start
Environment variables are the server version of form input. They are strings or missing values at the boundary, not trusted app configuration.
This pure TypeScript playground does not read process.env. It teaches the parser shape with a plain record, which is the same shape your startup file should apply to real env data.
→ config port 4173 in production
→ blocked: DATABASE_URL is required
The production version can use the Zod pattern from Section 13. This is read-only reference code; this page does not install Zod or run Node process globals.
import * as z from "zod";
const ConfigSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().pipe(z.int().min(1).max(65535)).default(3000),
DATABASE_URL: z.url(),
});
const parsed = ConfigSchema.parse(process.env);
export const config = {
mode: parsed.NODE_ENV,
port: parsed.PORT,
databaseUrl: parsed.DATABASE_URL,
};
export type Config = typeof config;The important placement is "at process start." Parse once, export a trusted config, and let the rest of the server depend on that typed value. Do not scatter process.env.DATABASE_URL reads through route handlers and hope every call site remembers the same checks.
Next lesson moves from process setup to API surfaces: typed request bodies, response envelopes, validators, and framework routes.
Checkpoint
Answer all three to mark this lesson complete