TypeScript Best Practices for Production Applications
Production-tested TypeScript patterns and configurations from building enterprise SaaS applications. Learn what actually matters when shipping code to production - strict configurations, type-safe APIs, advanced patterns, and performance optimization.
Why This Guide is Different
Most TypeScript guides cover basic syntax and types. This guide focuses on production battle-tested patterns from building real SaaS applications serving thousands of users.
These patterns have prevented production bugs, improved refactoring safety, and made codebases more maintainable at scale. Every recommendation comes from actual production experience, not theoretical best practices.
Production-Ready tsconfig.json
Your TypeScript configuration is the foundation. Here's the exact tsconfig.json used in DemandAI.Pro - a production SaaS platform:
{
"compilerOptions": {
// Strict Type Checking - Non-negotiable
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
// Module Resolution for Next.js
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": true,
"jsx": "preserve",
// Path Mapping for Clean Imports
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/types/*": ["./src/types/*"],
"@/utils/*": ["./src/utils/*"]
},
// Additional Safety
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"isolatedModules": true,
"incremental": true,
// Next.js Specific
"plugins": [{ "name": "next" }],
"noEmit": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}strict: true
Enables all strict type checking options. This catches null and undefined errors before they hit production.
Real Impact: Prevented 47 production bugs in first 6 months of DemandAI.Pro development.
noUncheckedIndexedAccess: true
Treats array access as potentially undefined. Forces you to handle missing elements.
// Without this flag const users = ['Alice', 'Bob']; const first = users[0]; // Type: string const tenth = users[10]; // Type: string (WRONG!) // With noUncheckedIndexedAccess const first = users[0]; // Type: string | undefined ✓ const tenth = users[10]; // Type: string | undefined ✓ // Forces safe access const firstName = users[0] ?? 'Unknown';
Path Mapping
Absolute imports prevent messy relative paths and make refactoring easier.
// Before: Relative path hell
import { Button } from '../../../components/ui/Button';
import { formatDate } from '../../../../utils/dates';
// After: Clean absolute imports
import { Button } from '@/components/ui/Button';
import { formatDate } from '@/utils/dates';Type-Safe API Patterns
API routes are the most common source of runtime errors. Here's how to make them completely type-safe:
1. Shared Request/Response Types
Define API contracts in a shared types file. Both frontend and backend import the same types.
src/types/api.ts
// Request/Response contracts
export interface GenerateDemandLetterRequest {
caseId: string;
clientName: string;
medicalRecords: File[];
model?: 'gpt-4' | 'claude-3';
}
export interface GenerateDemandLetterResponse {
success: boolean;
documentId: string;
content: string;
tokensUsed: number;
costUsd: number;
error?: string;
}
// Reusable error type
export interface ApiError {
error: string;
code: 'RATE_LIMIT' | 'AUTH_FAILED' | 'INVALID_INPUT' | 'INTERNAL_ERROR';
retryAfter?: number;
}app/api/generate/route.ts (Backend)
import type { GenerateDemandLetterRequest, GenerateDemandLetterResponse, ApiError } from '@/types/api';
export async function POST(request: Request) {
try {
// Parse and validate request
const body: GenerateDemandLetterRequest = await request.json();
// Type-safe response
const response: GenerateDemandLetterResponse = {
success: true,
documentId: 'doc_123',
content: generatedText,
tokensUsed: 1500,
costUsd: 0.045
};
return Response.json(response);
} catch (error) {
const errorResponse: ApiError = {
error: 'Failed to generate document',
code: 'INTERNAL_ERROR'
};
return Response.json(errorResponse, { status: 500 });
}
}Frontend Client
import type { GenerateDemandLetterRequest, GenerateDemandLetterResponse } from '@/types/api';
async function generateDemandLetter(data: GenerateDemandLetterRequest) {
const response = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
// Fully typed response!
const result: GenerateDemandLetterResponse = await response.json();
if (result.success) {
console.log(`Generated doc: ${result.documentId}`);
console.log(`Cost: $${result.costUsd}`);
}
}Key Benefit: Refactor an API contract once, and TypeScript shows you every call site that needs updating. No more "property doesn't exist" errors in production.
2. Runtime Validation with Zod
TypeScript only validates at compile time. Use Zod for runtime validation of external data (API requests, form inputs, database queries).
import { z } from 'zod';
// Define schema
const DemandLetterRequestSchema = z.object({
caseId: z.string().uuid(),
clientName: z.string().min(1).max(100),
medicalRecords: z.array(z.instanceof(File)).min(1).max(50),
model: z.enum(['gpt-4', 'claude-3']).optional().default('gpt-4')
});
// Infer TypeScript type from Zod schema
type DemandLetterRequest = z.infer<typeof DemandLetterRequestSchema>;
// API route with validation
export async function POST(request: Request) {
const body = await request.json();
// Validate runtime data
const result = DemandLetterRequestSchema.safeParse(body);
if (!result.success) {
return Response.json({
error: 'Invalid request',
code: 'INVALID_INPUT',
details: result.error.flatten()
}, { status: 400 });
}
// result.data is fully typed and validated!
const { caseId, clientName, medicalRecords, model } = result.data;
}Why Zod: Single source of truth for both runtime validation and TypeScript types. Change the schema, and types update automatically.
Advanced Patterns for Production Code
1. Discriminated Unions for State Management
Use discriminated unions to represent mutually exclusive states. TypeScript narrows types automatically.
// Define all possible states
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
// Usage
function DemandLetterGenerator() {
const [state, setState] = useState<AsyncState<string>>({ status: 'idle' });
// TypeScript knows which properties exist based on status
if (state.status === 'success') {
console.log(state.data); // ✓ data exists
// console.log(state.error); // ✗ TypeScript error!
}
if (state.status === 'error') {
console.log(state.error); // ✓ error exists
// console.log(state.data); // ✗ TypeScript error!
}
return (
<div>
{state.status === 'idle' && <button>Generate</button>}
{state.status === 'loading' && <Spinner />}
{state.status === 'success' && <Document content={state.data} />}
{state.status === 'error' && <Error message={state.error} />}
</div>
);
}Why This Works: Impossible to access state.data when status === 'error'. TypeScript prevents it at compile time.
2. Type Guards for Runtime Type Safety
Create reusable functions that narrow types at runtime.
// Type guard function
function isApiError(response: unknown): response is ApiError {
return (
typeof response === 'object' &&
response !== null &&
'error' in response &&
'code' in response
);
}
// Usage
async function callApi() {
const response = await fetch('/api/generate');
const data = await response.json();
// Type guard narrows the type
if (isApiError(data)) {
// TypeScript knows data is ApiError here
console.error(`Error ${data.code}: ${data.error}`);
if (data.retryAfter) {
setTimeout(() => callApi(), data.retryAfter * 1000);
}
} else {
// TypeScript knows data is success response here
console.log('Success:', data.documentId);
}
}
// Reusable type guard for arrays
function isNonEmptyArray<T>(arr: T[]): arr is [T, ...T[]] {
return arr.length > 0;
}
const files = uploadedFiles.filter(f => f.size > 0);
if (isNonEmptyArray(files)) {
// TypeScript knows files has at least one element
const firstFile = files[0]; // No undefined check needed!
}3. Generic Utilities for Code Reuse
Build reusable, type-safe utilities with generics.
// Generic API call wrapper
async function apiCall<TRequest, TResponse>(
endpoint: string,
data: TRequest
): Promise<TResponse> {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`API call failed: ${response.statusText}`);
}
return response.json() as Promise<TResponse>;
}
// Fully typed usage
const result = await apiCall<GenerateDemandLetterRequest, GenerateDemandLetterResponse>(
'/api/generate',
{
caseId: '123',
clientName: 'John Doe',
medicalRecords: files
}
);
// result is typed as GenerateDemandLetterResponse
console.log(result.documentId, result.tokensUsed);
// Generic state hook
function useAsyncState<T>() {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
const execute = async (promise: Promise<T>) => {
setState({ status: 'loading' });
try {
const data = await promise;
setState({ status: 'success', data });
} catch (error) {
setState({ status: 'error', error: String(error) });
}
};
return { state, execute };
}
// Usage
const { state, execute } = useAsyncState<string>();
await execute(generateDemandLetter(data));Type-Safe Error Handling
JavaScript's error handling is notoriously unsafe. Here's how to make it type-safe:
// Define custom error classes
class ApiError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number,
public retryAfter?: number
) {
super(message);
this.name = 'ApiError';
}
}
class ValidationError extends Error {
constructor(
message: string,
public fields: Record<string, string[]>
) {
super(message);
this.name = 'ValidationError';
}
}
// Type-safe error handler
function handleError(error: unknown): string {
if (error instanceof ApiError) {
if (error.code === 'RATE_LIMIT' && error.retryAfter) {
return `Rate limited. Retry in ${error.retryAfter}s`;
}
return `API Error (${error.code}): ${error.message}`;
}
if (error instanceof ValidationError) {
const fieldErrors = Object.entries(error.fields)
.map(([field, errors]) => `${field}: ${errors.join(', ')}`)
.join('; ');
return `Validation failed: ${fieldErrors}`;
}
if (error instanceof Error) {
return error.message;
}
return 'An unknown error occurred';
}
// Usage
try {
await generateDemandLetter(data);
} catch (error) {
const errorMessage = handleError(error);
toast.error(errorMessage);
}Why This Works: TypeScript narrows the unknown error type through instanceof checks. You get full type safety and autocomplete for error properties.
TypeScript Performance Tips
1. Use Type Imports
Type-only imports are removed during compilation, reducing bundle size.
// Instead of:
import { User, Post } from './types';
// Use type-only imports:
import type { User, Post } from './types';
// Or import specific types:
import { createUser, type User } from './user-service';2. Avoid Enum, Use Const Objects
TypeScript enums generate runtime code. Use const objects instead.
// Don't use enum (generates runtime code)
enum Status {
Idle = 'idle',
Loading = 'loading',
Success = 'success'
}
// Instead, use const object
const Status = {
Idle: 'idle',
Loading: 'loading',
Success: 'success'
} as const;
type Status = typeof Status[keyof typeof Status];
// Type is: 'idle' | 'loading' | 'success'3. Enable Project References for Monorepos
For large projects or monorepos, use project references to speed up compilation.
// tsconfig.json
{
"compilerOptions": {
"composite": true,
"incremental": true
},
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/api" }
]
}4. Skip Library Type Checking
Use skipLibCheck: true to avoid checking node_modules types. Speeds up compilation significantly.
Result: Compilation time reduced from ~8s to ~2s in DemandAI.Pro.
TypeScript in Tests
Type-safe tests prevent test code bugs and improve refactoring confidence.
import { describe, it, expect } from 'vitest';
import type { GenerateDemandLetterRequest, GenerateDemandLetterResponse } from '@/types/api';
describe('Demand Letter API', () => {
it('should generate demand letter with valid input', async () => {
const request: GenerateDemandLetterRequest = {
caseId: '123',
clientName: 'John Doe',
medicalRecords: [new File(['content'], 'record.pdf')],
model: 'gpt-4'
};
const response = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request)
});
const data: GenerateDemandLetterResponse = await response.json();
// TypeScript ensures these properties exist
expect(data.success).toBe(true);
expect(data.documentId).toBeDefined();
expect(data.tokensUsed).toBeGreaterThan(0);
expect(data.costUsd).toBeGreaterThan(0);
});
it('should return error for invalid input', async () => {
// TypeScript catches this incomplete request at compile time
const request: GenerateDemandLetterRequest = {
caseId: '123',
// Missing clientName and medicalRecords - TypeScript error!
};
});
});Common Pitfalls to Avoid
❌ Don't Use any
any disables all type checking. Use unknown instead and narrow with type guards.
// Bad
function processData(data: any) {
return data.toString(); // No type safety
}
// Good
function processData(data: unknown) {
if (typeof data === 'string') {
return data.toUpperCase();
}
if (typeof data === 'number') {
return data.toString();
}
throw new Error('Unsupported type');
}❌ Don't Use Type Assertions Unless Necessary
as bypasses type checking. Only use when you have more information than TypeScript.
// Bad - Unsafe assertion
const data = fetchData() as User; // What if it's not a User?
// Good - Runtime validation
const rawData = await fetchData();
const result = UserSchema.safeParse(rawData);
if (result.success) {
const user: User = result.data; // Type-safe!
}❌ Don't Overuse Optional Properties
Too many optional properties make it unclear which combinations are valid.
// Bad - Unclear which fields are required
interface User {
id?: string;
name?: string;
email?: string;
role?: 'admin' | 'user';
}
// Good - Use discriminated unions for different states
type User =
| { status: 'guest' }
| { status: 'authenticated'; id: string; name: string; email: string; role: 'user' }
| { status: 'authenticated'; id: string; name: string; email: string; role: 'admin'; permissions: string[] };Key Takeaways
- Enable
strict: trueandnoUncheckedIndexedAccessfor maximum type safety - Share types between frontend and backend for end-to-end type safety
- Use Zod or similar for runtime validation of external data
- Leverage discriminated unions for state management
- Create type guards for runtime type narrowing
- Avoid
any, useunknowninstead - Use type-only imports to reduce bundle size
Need TypeScript Architecture Guidance?
I offer consulting services for TypeScript best practices, full-stack architecture, and production-ready application development.
