TypeScript is not a magic solution to code quality. I've reviewed codebases where TypeScript was technically present but every other type was any, every function return was untyped, and the type coverage was so thin the code might as well have been plain JavaScript. TypeScript done right โ with strict settings, meaningful types, and consistent patterns โ is transformative. Done wrong, it's ceremony without substance.
I write TypeScript every day across multiple Node.js projects: multi-tenant SaaS backends, API servers, worker processes, and shared library packages. These are the practices I've converged on after iterating through what breaks teams at scale.
"The goal of TypeScript is not to make your code compile. It's to make incorrect code impossible to write in the first place."
Why TypeScript Discipline Matters at Scale
Small projects can survive TypeScript sloppiness. The developer who wrote the code remembers what shape the data is. There's one API endpoint. There are no teams, no PR reviews, no onboarding.
At scale, that context disappears. New engineers join. The original author moves to another team. Six months pass between touching a module. In these conditions, the type system is your documentation, your contract, and your first line of defense against regressions. A function that returns Promise<any> tells the next engineer nothing. A function that returns Promise<Result<User, AuthError>> tells them everything they need to know before they read a single line of implementation.
The patterns in this article optimize for: catching errors at compile time rather than runtime, reducing the cognitive load of reading unfamiliar code, and making refactoring safe enough that engineers will actually do it.
tsconfig.json: The Settings That Actually Matter
Most projects copy a tsconfig from Stack Overflow and never revisit it. Here's the configuration I use for production Node.js backends, with commentary on every important setting:
// tsconfig.json โ production Node.js backend
{
"compilerOptions": {
// Target: Node 20 supports ES2022 natively
"target": "ES2022",
"module": "NodeNext", // ESM + CJS interop, correct for modern Node
"moduleResolution": "NodeNext",
// Output
"outDir": "./dist",
"rootDir": "./src",
"declaration": true, // emit .d.ts files (required for shared packages)
"declarationMap": true, // map .d.ts to source for Go to Definition
"sourceMap": true,
// STRICT MODE โ non-negotiable
"strict": true, // enables all strict* flags below
"noUncheckedIndexedAccess": true, // arr[0] is T | undefined, not T
"exactOptionalPropertyTypes": true, // {a?: string} means absent, not undefined
// Additional safety
"noImplicitReturns": true, // all code paths must return
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true, // catch dead code
"noUnusedParameters": true, // catch dead parameters
"allowUnreachableCode": false,
"forceConsistentCasingInFileNames": true, // critical on Linux (case-sensitive FS)
// Path aliases (use with tsconfig-paths at runtime)
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
// Interop
"esModuleInterop": true,
"skipLibCheck": true, // skip type checking of .d.ts in node_modules
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
noUncheckedIndexedAccess is the single most impactful non-default setting. It forces you to acknowledge that array[0] might be undefined, preventing an entire class of runtime errors. Enable it from day one โ retrofitting it into an existing codebase is painful.
Folder Structure for Large Projects
The folder structure I've settled on separates concerns clearly and scales to teams of 5โ20 engineers without becoming a maze:
src/
โโโ config/ # env vars, constants, feature flags
โ โโโ env.ts # Zod-validated environment config (see below)
โ โโโ constants.ts
โ
โโโ types/ # shared domain types and interfaces
โ โโโ user.ts
โ โโโ api.ts # request/response shapes
โ โโโ index.ts # barrel export
โ
โโโ db/ # database layer
โ โโโ pool.ts # connection setup
โ โโโ migrations/
โ โโโ repositories/ # one file per entity
โ โโโ user.repository.ts
โ โโโ order.repository.ts
โ
โโโ services/ # business logic, no HTTP concerns
โ โโโ auth.service.ts
โ โโโ user.service.ts
โ โโโ email.service.ts
โ
โโโ routes/ # HTTP route handlers (thin controllers)
โ โโโ auth.routes.ts
โ โโโ user.routes.ts
โ โโโ index.ts
โ
โโโ middleware/ # Express/Fastify middleware
โ โโโ auth.middleware.ts
โ โโโ validate.middleware.ts
โ โโโ error.middleware.ts
โ
โโโ utils/ # pure functions, no side effects
โ โโโ result.ts # Result type implementation
โ โโโ logger.ts
โ โโโ crypto.ts
โ
โโโ server.ts # app bootstrap, exported for testing
The key rule: services never import from routes. Routes are thin HTTP adapters โ they validate input, call a service, and serialize the response. All business logic lives in services. All data access lives in repositories. This separation makes unit testing services trivial (no HTTP mocking needed) and makes it easy to add a queue worker or CLI command that reuses the same service logic.
Type-Safe API Responses with Zod
The boundary between your TypeScript types and the outside world (HTTP bodies, database rows, environment variables) is where runtime errors hide. TypeScript types only exist at compile time โ at runtime, an API client can send anything. Zod bridges this gap by providing runtime validation that generates TypeScript types automatically:
// types/user.ts
import { z } from 'zod';
// Define the schema once โ Zod infers the TypeScript type
export const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']).default('member'),
organizationId: z.string().uuid(),
});
// TypeScript type derived from the schema โ always in sync
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Reusable validation middleware
export function validate<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten(),
});
}
req.body = result.data; // typed and parsed
next();
};
}
// Usage in route
router.post('/users', validate(CreateUserSchema), userController.create);
Repository Pattern with Generics
The repository pattern gives you a clean interface between your business logic and your database. Using generics makes it reusable across all entity types without duplicating code:
// db/repositories/base.repository.ts
import { Pool } from 'pg';
export abstract class BaseRepository<T, CreateInput, UpdateInput> {
constructor(protected readonly pool: Pool, protected readonly table: string) {}
async findById(id: string): Promise<T | null> {
const { rows } = await this.pool.query(
`SELECT * FROM ${this.table} WHERE id = $1 AND deleted_at IS NULL`,
[id]
);
return (rows[0] as T) ?? null;
}
async findMany(where: Partial<T> = {}): Promise<T[]> {
const keys = Object.keys(where);
if (keys.length === 0) {
const { rows } = await this.pool.query(`SELECT * FROM ${this.table} WHERE deleted_at IS NULL`);
return rows as T[];
}
const conditions = keys.map((k, i) => `${k} = $${i + 1}`).join(' AND ');
const { rows } = await this.pool.query(
`SELECT * FROM ${this.table} WHERE ${conditions} AND deleted_at IS NULL`,
Object.values(where)
);
return rows as T[];
}
abstract create(input: CreateInput): Promise<T>;
abstract update(id: string, input: UpdateInput): Promise<T | null>;
}
// db/repositories/user.repository.ts
export class UserRepository extends BaseRepository<User, CreateUserInput, UpdateUserInput> {
constructor(pool: Pool) { super(pool, 'users'); }
async findByEmail(email: string): Promise<User | null> {
const { rows } = await this.pool.query(
'SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL', [email]
);
return (rows[0] as User) ?? null;
}
async create(input: CreateUserInput): Promise<User> { /* ... */ }
async update(id: string, input: UpdateUserInput): Promise<User | null> { /* ... */ }
}
Error Handling Done Right
JavaScript's error handling is untyped โ catch (e) gives you unknown in strict mode. The Result pattern makes errors explicit in the type signature, forcing callers to handle both the success and failure cases:
// utils/result.ts
export type Ok<T> = { ok: true; value: T };
export type Err<E> = { ok: false; error: E };
export type Result<T, E = Error> = Ok<T> | Err<E>;
export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });
// Typed domain errors
export class AuthError extends Error {
constructor(public readonly code: 'INVALID_CREDENTIALS' | 'TOKEN_EXPIRED' | 'UNAUTHORIZED', message: string) {
super(message);
this.name = 'AuthError';
}
}
// Service using Result pattern
async function loginUser(email: string, password: string): Promise<Result<AuthToken, AuthError>> {
const user = await userRepo.findByEmail(email);
if (!user) {
return err(new AuthError('INVALID_CREDENTIALS', 'User not found'));
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return err(new AuthError('INVALID_CREDENTIALS', 'Wrong password'));
}
return ok(generateToken(user));
}
// Caller is forced to handle both cases โ no silent failures
const result = await loginUser(email, password);
if (!result.ok) {
// result.error is typed as AuthError
if (result.error.code === 'INVALID_CREDENTIALS') {
return res.status(401).json({ error: 'Invalid email or password' });
}
}
// result.value is typed as AuthToken
res.json({ token: result.value.accessToken });
Environment Variables: Type-Safe Config
Environment variables are strings at runtime. Without validation, a missing DATABASE_URL causes a cryptic connection error 10 layers deep. With Zod validation at startup, you get a clear error the moment the process launches:
// config/env.ts
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.string().transform(Number).pipe(z.number().min(1024).max(65535)),
DATABASE_URL: z.string().url(),
REDIS_HOST: z.string().min(1),
JWT_SECRET: z.string().min(32), // enforce minimum secret length
AWS_REGION: z.string().default('ap-south-1'),
S3_BUCKET: z.string().optional(),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error('โ Invalid environment variables:');
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
// env.PORT is number (not string), env.DATABASE_URL is validated string
Avoiding the Most Common TypeScript Anti-Patterns
| Anti-Pattern | Correct Approach |
|---|---|
as any casting | Use type narrowing, generics, or unknown with guards. If you must cast, use as unknown as T and add a comment explaining why. |
Non-null assertion ! everywhere | Return early with proper null checks. user! hides runtime errors. if (!user) return makes them impossible. |
Implicit any in function params | Enable noImplicitAny (included in strict). Every parameter must be explicitly typed. |
Giant union types: string | number | boolean | object | null | undefined | Define a proper interface or discriminated union. Wide unions mean your function doesn't know what it's working with. |
Type assertions on API responses: response.data as User | Parse with Zod. API responses are unknown at runtime โ casting doesn't make them safe. |
Barrel re-exports from every folder (index.ts exporting everything) | Barrel files create circular dependency risks and slow TypeScript language server. Export explicitly from the module that owns the type. |
enum for string constants | Use const objects + as const: const Role = { Admin: 'admin' } as const; type Role = typeof Role[keyof typeof Role]. Enums compile to runtime code; as const does not. |
Testing TypeScript Code
Type safety and tests are complementary, not alternatives. Types catch the wrong-shape-of-data class of bugs. Tests catch the wrong-logic class of bugs. You need both.
For unit testing Node.js services, Vitest is my current default over Jest โ it's faster, uses native ESM, and has a compatible API. Structure tests to mirror your src/ structure in a tests/ directory.
The most important TypeScript-specific testing practice: test your Zod schemas. They are your system's input validation โ a schema bug means invalid data reaches your database silently. Write test cases for boundary values, invalid inputs, and the types your schema transforms (string to number, etc.).
For integration testing repository methods, use a real database (Postgres in a Docker container via testcontainers) rather than mocking. Mocking database calls tests your mocks, not your queries. The extra setup is worth the confidence you get back.
If you're building a TypeScript Node.js system and want a code review or an architecture consultation, drop me a message.