nt-gateway && cd payment-gateway
npm init -y
npm install express
npm install -D typescript @types/express @types/node tsx nodemon
npx tsc --init
`tsx` replaces `ts-node` for faster ESM execution. `nodemon` handles file watching during development.
### Step 2: Compiler Configuration Strategy
The `tsconfig.json` file dictates how the compiler validates and transforms code. Production configurations should prioritize soundness over convenience.
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "build", "**/*.test.ts"]
}
Architecture Rationale:
NodeNext + NodeNext resolution: Aligns with modern Node.js ESM standards while maintaining CommonJS compatibility.
strict: true: Enables all soundness checks. This is non-negotiable for production.
exactOptionalPropertyTypes: Prevents undefined from being assigned to optional fields, closing a common null-safety gap.
skipLibCheck: Skips type validation in node_modules, reducing compilation time by 40-60% in large projects.
declaration: true + sourceMap: true: Generates .d.ts files and debugging maps for downstream consumers and error tracing.
Step 3: Type Architecture & Domain Modeling
Interfaces define object shapes and class contracts. Type aliases handle unions, intersections, and computed mappings. Mixing them incorrectly leads to rigid or leaky abstractions.
// Domain contracts
interface PaymentRecord {
readonly transactionId: string;
amount: number;
currency: 'USD' | 'EUR' | 'GBP';
status: 'pending' | 'authorized' | 'settled' | 'failed';
metadata?: Record<string, string>;
processedAt: Date;
}
type PaymentProvider = 'stripe' | 'paypal' | 'square';
// Intersection for extended context
interface PaymentContext extends PaymentRecord {
provider: PaymentProvider;
retryCount: number;
}
// Utility type for partial updates
type PaymentUpdate = Partial<Pick<PaymentRecord, 'status' | 'metadata'>>;
// Generic repository contract
interface PaymentRepository<T> {
create(record: Omit<T, 'transactionId' | 'processedAt'>): Promise<T>;
findById(id: string): Promise<T | null>;
update(id: string, payload: PaymentUpdate): Promise<T>;
}
Why this structure works:
readonly prevents accidental mutation of immutable identifiers.
Pick + Partial creates safe update payloads without exposing internal fields.
Omit enforces that generated fields (transactionId, processedAt) cannot be manually injected.
- Generic repository contracts enable type-safe database adapters without coupling to specific ORMs.
Step 4: Safe Error Boundaries & Type Guards
Runtime failures must be caught, classified, and transformed into predictable responses. Custom error hierarchies combined with type guards eliminate unknown ambiguity.
// Error hierarchy
class DomainError extends Error {
constructor(
public readonly statusCode: number,
public readonly errorCode: string,
message: string,
public readonly context?: unknown
) {
super(message);
this.name = 'DomainError';
}
}
class InsufficientFundsError extends DomainError {
constructor(available: number, required: number) {
super(402, 'INSUFFICIENT_FUNDS', `Need ${required}, have ${available}`, { available, required });
}
}
class InvalidProviderError extends DomainError {
constructor(provider: string) {
super(400, 'INVALID_PROVIDER', `Unsupported payment provider: ${provider}`);
}
}
// Type guards
function isDomainError(value: unknown): value is DomainError {
return value instanceof DomainError;
}
function isStandardError(value: unknown): value is Error {
return value instanceof Error;
}
// Express error middleware
function handleGlobalError(
err: unknown,
_req: Request,
res: Response,
_next: NextFunction
): void {
if (isDomainError(err)) {
res.status(err.statusCode).json({
error: { code: err.errorCode, message: err.message, context: err.context }
});
return;
}
if (isStandardError(err)) {
console.error('Unhandled exception:', err.stack);
res.status(500).json({ error: { code: 'INTERNAL_FAILURE', message: 'Service unavailable' } });
return;
}
// Fallback for non-Error throws
console.error('Non-standard throw:', err);
res.status(500).json({ error: { code: 'UNKNOWN_FAILURE', message: 'Unexpected error' } });
}
Production Insight: Never throw strings or numbers. Always wrap failures in Error subclasses. Type guards ensure the middleware never crashes while inspecting unknown payloads. The context field enables structured logging without leaking sensitive data.
Pitfall Guide
1. The any Escape Hatch
Explanation: Developers use any to bypass compiler complaints when dealing with third-party APIs or legacy data. This disables all type checking for that variable, reintroducing runtime failures.
Fix: Replace any with unknown. Use type guards (typeof, instanceof, or custom predicates) to narrow the type before accessing properties.
2. Ignoring strict: true
Explanation: Leaving strict mode disabled allows implicit any, missing return types, and unsafe null assignments. The compiler becomes a suggestion engine rather than a safety gate.
Fix: Enable strict: true from day one. If migrating legacy code, use strict: false temporarily but incrementally enable individual flags (noImplicitAny, strictNullChecks, strictFunctionTypes) until full compliance is reached.
3. Interface vs Type Alias Misapplication
Explanation: Interfaces support declaration merging and class implementation. Type aliases support unions, intersections, and mapped types. Using interfaces for unions or types for class contracts creates rigid or invalid abstractions.
Fix: Use interface for object shapes, API contracts, and class implementations. Use type for unions, intersections, primitives, and computed mappings.
4. Unsafe Type Assertions (as)
Explanation: as forces the compiler to trust a type without validation. Overuse bypasses null checks and structural validation, leading to runtime crashes when data shape mismatches expectations.
Fix: Prefer runtime validation (Zod, io-ts, or manual guards) for external data. Reserve as for cases where you possess external knowledge the compiler cannot infer (e.g., DOM APIs, legacy module exports).
5. Neglecting Async Error Propagation
Explanation: Unhandled promise rejections crash Node.js processes. Developers often forget to wrap await calls in try/catch or return rejected promises without type alignment.
Fix: Always type async functions with Promise<T>. Use try/catch around I/O operations. Return Result<T, E> patterns or use Either types for explicit error handling in critical paths.
6. Overcomplicating Utility Types
Explanation: Chaining Pick, Omit, Partial, and Required creates deeply nested types that are difficult to debug and slow down the language server.
Fix: Flatten type compositions. Define explicit DTOs for API boundaries. Use utility types sparingly and document their intent. Prefer explicit interfaces for public contracts.
7. Skipping skipLibCheck in Large Projects
Explanation: Validating node_modules type definitions consumes significant compilation time and often fails due to third-party type inaccuracies.
Fix: Always enable skipLibCheck: true. It safely skips external declaration files while preserving strict validation for your source code.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Greenfield Microservice | Strict TS + Zod validation + NodeNext modules | Maximum safety, predictable contracts, fast IDE feedback | Low upfront, high long-term savings |
| Legacy JS Migration | Incremental strict flags + .js to .ts rename + skipLibCheck | Minimizes disruption, allows phased adoption | Medium upfront, reduces hotfix costs |
| Public NPM Library | declaration: true + strict: true + exactOptionalPropertyTypes | Consumers get accurate types, prevents misuse | Slight build overhead, higher adoption |
| Full-Stack Monorepo | Shared tsconfig base + workspace references + skipLibCheck | Consistent rules, faster builds, cross-package safety | Medium setup, scales linearly |
Configuration Template
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "build", "**/*.test.ts"]
}
// package.json scripts
{
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node build/index.js",
"typecheck": "tsc --noEmit",
"lint:types": "tsc --noEmit --pretty"
}
}
Quick Start Guide
- Initialize & Install: Run
npm init -y, then npm install -D typescript @types/node tsx nodemon.
- Generate Config: Execute
npx tsc --init and replace the generated file with the Production Bundle template.
- Create Entry Point: Add
src/index.ts with a basic Express server or Node script. Verify types compile with npm run typecheck.
- Run Development Server: Start with
npm run dev. The tsx watch command provides instant ESM execution with hot reloading.
- Enforce in CI: Add
npm run typecheck to your pipeline. Block merges if the compiler reports errors. Treat type warnings as build failures.
TypeScript's value compounds when treated as an architectural discipline rather than a syntax extension. Strict configuration, deliberate type boundaries, and safe error handling transform a dynamic language into a predictable engineering platform. The initial verbosity pays for itself in reduced debugging cycles, safer refactoring, and consistent team velocity.