TypeScript for Backend Development: Patterns I Use on Every Project
The TypeScript backend patterns I apply consistently — type-safe configs, error handling, validated API inputs, utility types, and the project structure that scales with the team.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
TypeScript on the backend is not just JavaScript with type annotations. The patterns that work in a frontend SPA do not always translate. Backend code has different concerns — long-running processes, database connections, error handling at every layer, external API integrations that can fail — and the TypeScript patterns that handle these concerns well are different from what most tutorials cover.
Here are the patterns I apply to every production Node.js TypeScript project.
The Strict Baseline
Start with strict TypeScript. Every project:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true
}
}
exactOptionalPropertyTypes is the flag most people leave off. It prevents the footgun where an optional property (field?: string) can be set to undefined explicitly. With this flag enabled, { field: undefined } and {} are different shapes — as they should be.
Type-Safe Environment Configuration
Never access process.env directly throughout your codebase. Parse and validate all environment variables at startup:
// src/config.ts
import { z } from 'zod'
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32),
API_KEY: z.string().min(1),
})
const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
console.error('Invalid environment configuration:')
console.error(parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const config = parsed.data
// config.DATABASE_URL is typed as string
// config.PORT is typed as number
// config.REDIS_URL is typed as string | undefined
This validation runs at application startup. If a required environment variable is missing or malformed, the application fails immediately with a clear error message rather than failing silently at the moment the variable is first accessed in a request handler.
Error Handling
The most common TypeScript backend mistake is shallow error handling. Every error that can reach a user should be typed, every error that represents a bug should be logged, and every layer of the application should handle errors explicitly.
Define your error hierarchy:
// src/errors.ts
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
public readonly details?: unknown
) {
super(message)
this.name = 'AppError'
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} with id ${id} not found`, 404, 'NOT_FOUND')
}
}
export class ValidationError extends AppError {
constructor(details: unknown) {
super('Validation failed', 422, 'VALIDATION_ERROR', details)
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED')
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN')
}
}
Handle them consistently in your framework's error middleware:
// Error handler for Hono
app.onError((err, c) => {
if (err instanceof AppError) {
return c.json({
error: {
code: err.code,
message: err.message,
details: err.details,
},
}, err.statusCode as StatusCode)
}
// Unexpected error — log it but don't expose details
console.error('Unhandled error:', err)
return c.json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
}, 500)
})
Result Types for Expected Failures
Not every failure is an exception. Database operations that find no record, API calls that return 404, file reads that find no file — these are expected outcomes, not exceptions. Modeling them as exceptions leads to try/catch hell.
Use a Result type for operations with expected failure modes:
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E }
// Usage
async function findUser(id: string): Promise<Result<User, 'NOT_FOUND' | 'DB_ERROR'>> {
try {
const user = await db.select().from(users).where(eq(users.id, id)).get()
if (!user) return { success: false, error: 'NOT_FOUND' }
return { success: true, data: user }
} catch {
return { success: false, error: 'DB_ERROR' }
}
}
// Caller must handle both cases
const result = await findUser(userId)
if (!result.success) {
if (result.error === 'NOT_FOUND') throw new NotFoundError('User', userId)
throw new AppError('Database error', 500, 'DB_ERROR')
}
const user = result.data // typed as User
This pattern makes failure handling explicit in function signatures and forces callers to handle error cases.
Validated API Request Parsing
Every API endpoint input goes through validation. Define schemas with Zod and create a typed parsing utility:
import { z } from 'zod'
// Schema definitions live with the route handler
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).max(10).default([]),
publishedAt: z.string().datetime().optional(),
})
export type CreatePostInput = z.infer<typeof createPostSchema>
// In route handler
app.post('/posts', async (c) => {
const body = await c.req.json().catch(() => null)
const parsed = createPostSchema.safeParse(body)
if (!parsed.success) {
throw new ValidationError(parsed.error.flatten())
}
const post = await createPost(parsed.data)
return c.json(post, 201)
})
Utility Types for API Responses
Define consistent response shapes with utility types:
// src/types/api.ts
export type ApiResponse<T> = {
data: T
meta?: {
timestamp: string
version: string
}
}
export type PaginatedResponse<T> = ApiResponse<T[]> & {
pagination: {
page: number
limit: number
total: number
pages: number
}
}
export type ApiError = {
error: {
code: string
message: string
details?: unknown
}
}
// Helper functions
export function success<T>(data: T): ApiResponse<T> {
return {
data,
meta: {
timestamp: new Date().toISOString(),
version: '1.0',
},
}
}
export function paginated<T>(
data: T[],
page: number,
limit: number,
total: number
): PaginatedResponse<T> {
return {
data,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
meta: {
timestamp: new Date().toISOString(),
version: '1.0',
},
}
}
Service Layer Pattern
Business logic belongs in service classes, not route handlers. Keep handlers thin:
// src/services/PostService.ts
export class PostService {
constructor(private db: Database) {}
async createPost(input: CreatePostInput, authorId: string): Promise<Post> {
const post = await this.db
.insert(posts)
.values({
id: createId(),
title: input.title,
content: input.content,
authorId,
publishedAt: input.publishedAt ? new Date(input.publishedAt) : null,
createdAt: new Date(),
})
.returning()
.get()
if (input.tags.length > 0) {
await this.tagPost(post.id, input.tags)
}
return post
}
async getPost(id: string): Promise<Post> {
const post = await this.db
.select()
.from(posts)
.where(eq(posts.id, id))
.get()
if (!post) throw new NotFoundError('Post', id)
return post
}
// ... other methods
}
The route handler becomes a thin coordinator:
app.post('/posts', requireAuth, async (c) => {
const body = await parseBody(c, createPostSchema)
const post = await postService.createPost(body, c.get('userId'))
return c.json(success(post), 201)
})
Testing TypeScript Backend Code
Type safety does not replace tests, but it does change what you need to test. Focus tests on business logic in services, not on TypeScript type correctness (the compiler handles that).
import { describe, it, expect, vi } from 'vitest'
import { PostService } from '../PostService'
describe('PostService', () => {
const mockDb = {
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnThis(),
get: vi.fn(),
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
}
const service = new PostService(mockDb as unknown as Database)
it('creates a post', async () => {
const mockPost = { id: 'p1', title: 'Test', content: 'Content', authorId: 'u1' }
mockDb.get.mockResolvedValue(mockPost)
const result = await service.createPost(
{ title: 'Test', content: 'Content', tags: [] },
'u1'
)
expect(result).toEqual(mockPost)
})
it('throws NotFoundError for missing post', async () => {
mockDb.get.mockResolvedValue(null)
await expect(service.getPost('nonexistent')).rejects.toThrow('Post')
})
})
TypeScript on the backend is a significant investment that pays back in the form of fewer runtime errors, more confident refactoring, and better IDE support. The patterns above are not optional niceties — they are the difference between TypeScript as a linter and TypeScript as a genuine safety net.
Building a TypeScript backend and want help designing the architecture or reviewing your patterns? Book a call: calendly.com/jamesrossjr.