We inherited a TypeScript codebase last year with 150K lines of code and 847 instances of any. The types existed but didn't protect anything — the team had TypeScript's type system turned off in practice. Bugs that types should have caught were hitting production weekly.
Over 6 weeks, we eliminated every any, enabled strict mode, and added discriminated unions for state management. Production bugs dropped 62%. Not because we wrote better code — because the compiler caught mistakes before they shipped.
At Pillai Infotech, TypeScript is mandatory for all new projects. This guide covers the practices we enforce across our codebases — not TypeScript basics, but the patterns that make large codebases maintainable.
Strict Mode Is Non-Negotiable
Your tsconfig.json should start with this:
{
"compilerOptions": {
"strict": true, // Enables ALL strict checks
"noUncheckedIndexedAccess": true, // Arrays can be undefined
"exactOptionalPropertyTypes": true, // undefined !== missing
"noImplicitOverride": true, // Explicit override keyword
"forceConsistentCasingInFileNames": true
}
}
The strict flag enables: strictNullChecks, strictFunctionTypes, strictPropertyInitialization, noImplicitAny, noImplicitThis, alwaysStrict, useUnknownInCatchVariables.
If strict mode isn't on, you're writing JavaScript with extra steps. The entire value proposition of TypeScript is compile-time safety — without strict mode, you don't have it.
The Most Important Strict Check: noUncheckedIndexedAccess
// Without noUncheckedIndexedAccess (dangerous)
const items = [1, 2, 3];
const first = items[0]; // type: number ← WRONG, could be undefined
// With noUncheckedIndexedAccess (safe)
const first = items[0]; // type: number | undefined ← CORRECT
if (first !== undefined) {
console.log(first.toFixed(2)); // type: number (narrowed)
}
This single flag catches an entire category of runtime errors — accessing arrays or objects by index where the value might not exist.
Type Patterns That Scale
Discriminated Unions for State
The most powerful TypeScript pattern for complex state:
// Instead of: { data: T | null, error: Error | null, loading: boolean }
// Which allows impossible states like { data: someData, error: someError }
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function render(state: AsyncState<User>) {
switch (state.status) {
case 'idle': return <Placeholder />;
case 'loading': return <Spinner />;
case 'success': return <Profile user={state.data} />; // data is typed
case 'error': return <Error message={state.error.message} />;
// TypeScript enforces exhaustiveness — miss a case, get a compile error
}
}
This pattern eliminates impossible states by construction. You can't have both data and error. You can't forget to handle a state. The compiler guarantees correctness.
Branded Types for Domain Safety
// Without branded types — easy to mix up IDs
function getUser(userId: string): User { ... }
function getOrder(orderId: string): Order { ... }
getUser(orderId); // Compiles fine! Bug at runtime.
// With branded types — compile-time safety
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function createUserId(id: string): UserId { return id as UserId; }
function createOrderId(id: string): OrderId { return id as OrderId; }
function getUser(userId: UserId): User { ... }
function getOrder(orderId: OrderId): Order { ... }
getUser(orderId); // COMPILE ERROR — can't pass OrderId as UserId
Use branded types for IDs, currencies, units, and any domain value where mixing types would be a bug.
Const Assertions for Literals
// Without as const — types widen
const config = {
apiUrl: 'https://api.example.com', // type: string
timeout: 5000, // type: number
retries: 3 // type: number
};
// With as const — types are literal
const config = {
apiUrl: 'https://api.example.com', // type: 'https://api.example.com'
timeout: 5000, // type: 5000
retries: 3 // type: 3
} as const;
Template Literal Types
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiPath = `/api/v1/${string}`;
type RouteKey = `${HttpMethod} ${ApiPath}`;
// Type-safe route definitions
const routes: Record<RouteKey, Handler> = {
'GET /api/v1/users': handleListUsers,
'POST /api/v1/users': handleCreateUser,
'GET /api/v1/orders': handleListOrders,
// 'PATCH /api/v1/users': ... ← Compile error! PATCH not in HttpMethod
};
Patterns to Avoid
1. any — The Type System Off Switch
Every any is a type-safety hole. Use unknown instead — it forces you to narrow the type before using it.
// Bad
function parse(input: any) {
return input.data.items; // No safety
}
// Good
function parse(input: unknown) {
if (typeof input === 'object' && input !== null && 'data' in input) {
// Type narrowed, safe to access
}
}
// For JSON parsing, use a schema validator
import { z } from 'zod';
const Schema = z.object({ data: z.object({ items: z.array(z.string()) }) });
const result = Schema.parse(input); // Type-safe at runtime + compile-time
2. Type Assertions (as) Without Validation
// Dangerous — tells the compiler to trust you (don't)
const user = response.data as User;
// Safe — validate at the boundary
const user = UserSchema.parse(response.data); // Zod
const user = decode(UserCodec)(response.data); // io-ts
3. Overloading With Generics
// Over-engineered — 4 levels of generics for a simple function
function transform<T extends Record<K, V>, K extends string, V, R>(
obj: T, fn: (key: K, value: V) => R
): Record<K, R> { ... }
// Just write it simply
function transformValues<T, R>(
obj: Record<string, T>, fn: (value: T) => R
): Record<string, R> { ... }
If your types are harder to read than the implementation, simplify them.
4. Enums (Use Union Types Instead)
// Avoid — TypeScript enums have runtime behavior and quirks
enum Status { Active, Inactive, Pending }
// Prefer — union types are simpler and more predictable
type Status = 'active' | 'inactive' | 'pending';
// If you need enum-like objects, use as const
const STATUS = { ACTIVE: 'active', INACTIVE: 'inactive', PENDING: 'pending' } as const;
type Status = typeof STATUS[keyof typeof STATUS];
Error Handling: Beyond try/catch
The Result Pattern
Instead of throwing exceptions (untyped, invisible in function signatures), return typed results:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseConfig(raw: string): Result<Config, ParseError> {
try {
const parsed = JSON.parse(raw);
const config = ConfigSchema.parse(parsed);
return { ok: true, value: config };
} catch (e) {
return { ok: false, error: new ParseError('Invalid config', { cause: e }) };
}
}
// Caller MUST handle the error — the type system enforces it
const result = parseConfig(rawInput);
if (!result.ok) {
logger.error('Config parse failed', result.error);
return fallbackConfig;
}
// result.value is Config here — type narrowed
This pattern makes error handling explicit and type-safe. You can't forget to handle errors — the compiler won't let you access .value without checking .ok first.
Custom Error Types
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number,
public readonly context?: Record<string, unknown>
) {
super(message);
this.name = 'AppError';
}
}
// Specific errors
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, 'NOT_FOUND', 404, { resource, id });
}
}
class ValidationError extends AppError {
constructor(field: string, reason: string) {
super(`Validation failed: ${field} — ${reason}`, 'VALIDATION', 400, { field });
}
}
API & Data Types
Validate at Boundaries, Trust Internally
External data (API responses, user input, database results) must be validated. Internal function parameters don't — the type system handles it.
// Boundary: validate with Zod
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user', 'viewer']),
});
type User = z.infer<typeof UserSchema>; // Generate TS type from schema
// API handler — validate input
app.post('/users', async (req, res) => {
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
// result.data is typed as User — validated and safe
const user = await createUser(result.data);
return res.json(user);
});
// Internal function — trust the type system
async function createUser(input: User): Promise<User> {
// No need to re-validate — TypeScript guarantees the types
return db.user.create({ data: input });
}
End-to-End Type Safety with tRPC
For full-stack TypeScript applications, tRPC eliminates the API layer. Types flow from server to client without code generation:
// Server: define procedures
const appRouter = router({
getUser: procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.user.findUnique({ where: { id: input.id } });
}),
});
// Client: full type inference — no API types to maintain
const user = await trpc.getUser.query({ id: '123' });
// user is typed as User | null — inferred from server return type
Advanced Patterns
Exhaustive Checks with never
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
type Shape = { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'rectangle': return shape.width * shape.height;
case 'triangle': return 0.5 * shape.base * shape.height;
default: return assertNever(shape); // Compile error if a case is missed
}
}
Builder Pattern with Type Inference
class QueryBuilder<T extends Record<string, unknown>> {
private filters: Partial<T> = {};
where<K extends keyof T>(key: K, value: T[K]) {
this.filters[key] = value;
return this; // Chainable
}
build(): { filters: Partial<T> } {
return { filters: this.filters };
}
}
// Usage — fully type-safe
const query = new QueryBuilder<User>()
.where('role', 'admin') // ✅ 'admin' is valid for role
.where('name', 42) // ❌ Compile error — 42 is not string
.build();
Tooling & Configuration
Essential Tools
- Biome: Fast linter + formatter (replacing ESLint + Prettier for many teams). Written in Rust, 10-100x faster.
- Zod: Runtime schema validation with TypeScript type inference. The standard for API input validation.
- tsx: Run TypeScript directly without compilation step.
tsx watch src/server.tsfor development. - Vitest: Fast test runner with native TypeScript support. Drop-in replacement for Jest, 5-10x faster.
- Drizzle ORM: Type-safe SQL queries. Like Prisma but SQL-first with better performance.
Frequently Asked Questions
Should new projects use TypeScript in 2026?
Yes, without exception. The ecosystem has fully embraced TypeScript — libraries ship types, frameworks expect types, and AI coding tools generate better TypeScript than JavaScript. The only reason to use JavaScript is for quick scripts that won't be maintained.
How do I migrate a JavaScript project to TypeScript?
Gradually. Rename files from .js to .ts one at a time. Start with strict: false and progressively enable strict checks. Use @ts-expect-error for existing code you'll fix later. Target: eliminate all @ts-expect-error comments within 3-6 months. Never allow new files without strict typing.
Is Zod or io-ts better for runtime validation?
Zod for most teams — simpler API, better docs, larger ecosystem. io-ts for teams that want functional programming patterns (fp-ts). Valibot is a lighter alternative to Zod with smaller bundle size. All three generate TypeScript types from schemas, which is the key feature.
Should I use interfaces or types?
Use type for most things — unions, intersections, mapped types, and general type aliases. Use interface only for object shapes that will be extended (class implementations, declaration merging). In practice, type covers 95% of use cases.
How strict should strict mode be?
Maximum. Enable strict: true plus noUncheckedIndexedAccess, exactOptionalPropertyTypes, and noImplicitOverride. Every flag you skip is a class of bugs you'll find in production instead of at compile time.
What about JSDoc types instead of TypeScript?
JSDoc types work for simple projects and avoid a build step. For anything larger than a few files, TypeScript is significantly better — discriminated unions, generics, mapped types, and refactoring tools that JSDoc can't match. The Svelte team's move to JSDoc is an exception, not a trend.