Free lesson · Node.js Deep Dive
Project Setup: TypeScript, Fastify, Config
Lesson 26.1 - Project Setup: TypeScript, Fastify, Config
The first lesson covers what most engineers spend least time on and yet sets the tone for everything after: project setup. The choice of framework, TypeScript config, env validation, module system, dev workflow - these shape your daily experience for the project's lifetime. Done well, they fade into the background; done badly, you fight them every day.
I'll walk through the capstone's setup choices, why each one is what it is, and where alternatives would be reasonable. The code is in capstone-link-in-bio/; this lesson is the why.
Why Fastify
Three framework options were realistic - Express, Fastify, Hono. The capstone uses Fastify. The reasoning:
flowchart LR
Express[Express]
Fastify[Fastify]
Hono[Hono]
Express --> E1["Universal familiarity<br/>Massive ecosystem<br/>v5 (2024) added async support"]
Express --> E2["No first-class validation<br/>Plugin model is loose<br/>Slower than alternatives"]
Fastify --> F1["First-class validation via schemas<br/>Plugin model with encapsulation<br/>Fast (binary-ish JSON)<br/>Strong TypeScript inference"]
Fastify --> F2["Smaller ecosystem than Express<br/>Slightly different mental model"]
Hono --> H1["Edge-runtime first<br/>Excellent TypeScript<br/>Tiny"]
Hono --> H2["Newer, smaller ecosystem<br/>Best for edge deploys<br/>Less Node-server lineage"]
style F1 fill:#34d399,stroke:#10b981,color:#000
style F2 fill:#fbbf24,stroke:#f59e0b,color:#000
A comparison of three Node frameworks. Express has universal familiarity, a massive ecosystem, and v5 finally added async support - but lacks first-class validation, its plugin model is loose, and it's noticeably slower than alternatives. Fastify has built-in validation via schemas, a plugin model with encapsulation, strong TypeScript inference, and is fast - its trade-offs are a smaller ecosystem than Express and a slightly different mental model. Hono is edge-runtime first with excellent TypeScript support and tiny footprint, but has a newer and smaller ecosystem and is best suited for edge deploys. For a Node server capstone, Fastify is the strongest fit.
For the capstone specifically, Fastify wins on three dimensions:
Validation integration. Fastify uses JSON Schema (or Zod via TypeProvider) for body, query, params, and headers. The validation runs before your handler. With Express, every handler does its own validation. The capstone uses Zod's .parse() in handlers, which is clean and explicit; with Fastify's typing this works seamlessly.
Plugin encapsulation. Fastify plugins can register their own routes, hooks, and decorators in a scope. The capstone uses this for the auth plugin (decorates the app with authenticate) and the error handler (sets a single error handler for the whole tree).
Pino integration. Fastify's logger is Pino by default. Pass a custom Pino instance and you get request-scoped logging with trace IDs automatically.
If you've never used Fastify and the ecosystem matters more to you, Express is fine. The patterns transfer almost exactly. The capstone's structure could be Express-based with a few hundred lines of plumbing differences.
TypeScript Configuration
The tsconfig.json in the capstone:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true
},
"include": ["src/**/*"]
}
The decisions:
target: ES2022. Modern Node (22+) supports it natively. No down-leveling needed. If you target ES2018 you'll emit code that works on older Node but doesn't take advantage of modern features.
module: NodeNext. This is the modern setting for Node ESM. It tells TypeScript to use Node's actual module resolution including the .js extension in imports (more on this below).
strict: true. Non-negotiable. Without it, TypeScript is a slightly fancier JavaScript - you lose most of the value.
noUncheckedIndexedAccess: true. This is the one most people miss. Without it, arr[0] is typed as T even when the array is empty. With it, arr[0] is T | undefined - you have to handle the missing case. Catches real bugs.
noImplicitOverride, noFallthroughCasesInSwitch. Small strictness wins that prevent specific bug classes.
Lesson 19.2 has the full tour of tsconfig options; this is the production-ready subset.
The .js Import Extensions
A confusion point that catches everyone the first time. Notice in the source files:
import { config } from './config.js';
import { logger } from '../lib/logger.js';
The files on disk are .ts. Why do we import them as .js?
Because module: NodeNext follows Node's ESM resolution rules, which require explicit file extensions in imports. The TypeScript compiler emits .js files; those files will import each other using .js extensions; TypeScript wants your source to match what the emitted code will look like.
It feels weird. Everyone has the same reaction. After a week it's invisible. Lesson 19.5 has the deeper explanation.
The alternative is "module": "CommonJS" which doesn't have this requirement but is the legacy module system. ESM is the future; bite this bullet now.
Config Validation at Boot
The src/config.ts file is the pattern from lesson 20.3:
const ConfigSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'staging', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url(),
JWT_ACCESS_SECRET: z.string().min(32, 'JWT_ACCESS_SECRET must be at least 32 chars'),
// ...
});
function loadConfig(): Config {
const parsed = ConfigSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid configuration:');
console.error(JSON.stringify(parsed.error.format(), null, 2));
process.exit(1);
}
return parsed.data;
}
export const config = loadConfig();
The reasons this matters:
Process exits at startup, not at first failure. If DATABASE_URL is missing, the process exits in the first 100ms with a clear message. The alternative is the service starting, then crashing on the first request that needs the database - much harder to diagnose.
Typed access everywhere. config.DATABASE_URL is typed as string. No process.env.DATABASE_URL!-with-non-null-assertion scattered through the code.
One source of truth. All config reads go through this module. If a value is wrong, you debug one file, not fifteen.
Validation specifics. The JWT secrets require 32+ characters. The URL fields validate they're URLs. The numbers coerce strings to numbers (env vars are always strings).
The pattern is small (~50 lines) and high-leverage. Every production Node service should have something like it.
Two JWT Secrets
A specific decision worth explaining. The config has:
JWT_ACCESS_SECRET: z.string().min(32),
JWT_REFRESH_SECRET: z.string().min(32),
Two different secrets. Why?
The access token (short-lived, ~15 min) is the one your client sends with every API request. It's verified often; in a busy service it might be exposed in many places (logs accidentally, browser network tabs, error reports).
The refresh token (long-lived, ~7 days) is sent only when the access token expires. Its only job is to issue new access tokens.
If you sign both with the same secret:
- A leaked access secret compromises refresh too.
- A refresh token could be presented as an access token (and would verify) if you don't separately check the
typeclaim.
With two secrets:
- Each can be rotated independently.
- A leaked access secret doesn't affect refresh.
- Refresh tokens literally can't be used as access tokens because they verify with the wrong key.
It's two extra environment variables for substantially better security properties. Worth it.
Module System and Package.json
The package.json has "type": "module". This makes every .js file ESM by default. Combined with module: NodeNext in tsconfig, the whole project is ESM end-to-end.
The scripts are minimal but functional:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"dev:worker": "tsx watch src/worker.ts",
"build": "tsc",
"start": "node --enable-source-maps dist/server.js",
"test": "node --test --import tsx/esm 'tests/**/*.test.ts'",
"type-check": "tsc --noEmit",
"migrate": "tsx src/db/migrate.ts"
}
}
tsx watch for dev. Runs TypeScript directly with hot reload. Modern Node 22+ can also strip types natively (node --experimental-strip-types) but tsx is more mature and handles ESM cleanly.
tsc for build. Just emit JavaScript. Type-checking is part of tsc by default. For production we serve the emitted .js files.
node --test for tests. Built-in test runner since Node 18+. No Jest, no Vitest. Lesson 19.3 covered why.
--enable-source-maps in production. Stack traces map back to TypeScript line numbers. Worth the small perf cost.
type-check runs tsc --noEmit. Pure type checking, no output. Used in CI alongside the test run.
Dev Environment with Docker Compose
The docker-compose.yml runs Postgres and Redis locally. You install Node and the project deps natively; the dependencies (DB and cache) run in containers.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: linkbio
POSTGRES_PASSWORD: linkbio
POSTGRES_DB: linkbio_dev
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U linkbio"]
interval: 5s
The trade-off: should you run everything in Docker (including the API) or only the dependencies?
- All in Docker: consistent across team, but slower iteration (rebuild on changes).
- Only deps in Docker: fast iteration on app code, but you need Node locally.
For solo dev, only-deps is better. For onboarding new team members on inconsistent OSes, all-in is better. The capstone supports both: docker compose up runs everything; docker compose up postgres redis runs just the deps.
The healthchecks are important - without them, the API container might start before Postgres is accepting connections, and your first migration run fails.
The Sensible Plugins
The capstone uses a few small plugins beyond Fastify core:
@fastify/sensible. Adds reply.notFound(), reply.badRequest() etc. as convenience methods. Small but useful.
@fastify/rate-limit. Production-quality rate limiting. Plugged at the route level so different routes can have different limits.
@fastify/jwt. JWT signing and verification. Lesson 17.7-adjacent (rate limiting) and 16.x (auth) covered the patterns.
Each is small, well-maintained, and replaces homegrown code that you'd otherwise have to write and test yourself.
What I Want You to Take From This Lesson
Six things.
One: Fastify is the right Node framework for new production services. Express works too; Fastify is better.
Two: TypeScript strict, plus noUncheckedIndexedAccess. Anything less is paying TypeScript's cost without its benefit.
Three: Module: NodeNext, ESM, .js import extensions. The pain is real for a week, then invisible.
Four: Validate config at boot with Zod. Fail fast on bad config.
Five: Two JWT secrets - access and refresh - not one.
Six: Docker Compose for local deps only, not necessarily for the app.
Try It
Exercise: Warmup (15 minutes). Read
package.jsonandtsconfig.jsonin the capstone. For each setting you don't recognize, look it up. You should be able to defend every choice.
Exercise: Core (30 minutes). Get the project running.
cp .env.example .env,docker compose up -d postgres redis,npm install,npm run migrate,npm run dev. Hit/livez. Verify it works.
Exercise: Core (30 minutes). Change
JWT_ACCESS_SECRETto a too-short value. Watch the process exit at startup with a clear error message. Compare to what would happen if config wasn't validated.
Exercise: Stretch (45 minutes). Port the same setup to Express + Zod. Get to the same place - strict TypeScript, validated config, ESM, dev workflow. Compare the code volume; note where Fastify's defaults saved you work.
What's Next
Lesson 26.2 covers the data layer - Postgres, migrations, the Redis cache. The foundation that everything stateful sits on.