TypeScript on the Server
AdvancedNode with TypeScript — module resolution, environment variables, and where a typed backend genuinely pays off in a full-stack app.
Overview
A full-stack JavaScript engineer is expected to be credible on the server too, and Node with TypeScript has its own set of sharp edges: two module systems that do not quite agree, environment variables that are all `string | undefined`, and a runtime that has changed how it handles TypeScript three times in as many years. The payoff is real though — when both ends of a request share the same types, a renamed field is a compile error rather than a support ticket.
Modules and Setup
CommonJS and ESM, and the config that decides which you get.
// package.json
{ "type": "module" } // .js files are ESM
// without it, .js is CommonJS
// tsconfig for modern Node
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"types": ["node"]
}
}
// With NodeNext, relative ESM imports need the extension —
// and it is the OUTPUT extension, which trips everyone up:
import { score } from './scoring.js' // even though the file is .ts
// Running it
node --experimental-strip-types src/index.ts // Node 22+
tsx src/index.ts // the common dev tool
// Both strip types without checking — still run tsc --noEmit separately.
// __dirname does not exist in ESM:
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))Environment and Config
process.env is string | undefined for everything. Validate it once at startup.
process.env.PORT // string | undefined — always
process.env.PORT + 1 // '30001' if you forget
// Validate the whole environment at boot, and fail loudly
import { z } from 'zod'
const Env = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
})
export const env = Env.parse(process.env)
// env.PORT is number, env.DATABASE_URL is a validated string,
// and a missing secret crashes on startup rather than on the
// first login attempt at 2am.
// Never log the parsed object — it holds secrets.Sharing Types Across the Stack
The actual advantage of TypeScript on both ends.
// A shared package, imported by both server and client
// packages/shared/src/problem.ts
export const ProblemSchema = z.object({ slug: z.string(), ... })
export type Problem = z.infer<typeof ProblemSchema>
// server: validates outgoing shape
res.json(ProblemSchema.parse(row))
// client: same type, no duplication
import type { Problem } from '@aicancode/shared'
// Rename 'slug' to 'id' in one file and both ends fail to compile.
// That is the whole argument for a TypeScript backend.
// Note for a Python backend: this does not apply.
// With FastAPI, the equivalent link is the generated OpenAPI client —
// Pydantic models on one side, generated TS types on the other.
// Same principle, different mechanism.Key Points to Remember
- 1"type": "module" plus module: NodeNext is the modern setup; relative ESM imports need the output .js extension
- 2__dirname does not exist in ESM — derive it from import.meta.url
- 3Every process.env value is string | undefined; parse and coerce the whole environment once at startup
- 4tsx and node --experimental-strip-types run TypeScript without checking it, so keep tsc --noEmit in CI
- 5A shared schema package makes a renamed field a compile error on both ends — the main reason to run TypeScript on the server
Interview Questions
Sign in to ask AriaWhy do ESM imports in a NodeNext TypeScript project use a .js extension for .ts files?
What type does process.env.PORT have, and how should configuration be handled?
What is the practical benefit of running TypeScript on both the client and the server?
Ask Aria about TypeScript on the Server
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.