Clean Code Principles: How to Write Readable and Maintainable TypeScript
Learn clean code principles in TypeScript to write readable, maintainable, and scalable software. Includes real-world guidelines and code examples.
Most developers think TypeScript's primary job is preventing undefined errors in production. That's true, but it's only half the story. The real superpower of TypeScript is communicating intent. Six months from now, when you or a teammate revisit your module, your type signatures and architecture tell the story of what your software actually does.
Yet too many codebases treat TypeScript as an adversary: sprinkling 'as unknown as any' whenever the compiler complains, writing 300-line monolithic functions, and chaining five boolean flags to represent UI states. Writing clean TypeScript requires unlearning habits inherited from untyped JavaScript.
The Readability Multiplier
Code is read ten times more often than it is written. Saving 20 seconds while typing an ambiguous shortcut costs hours of debugging downstream.
Intent-revealing names and single responsibility
Names should answer three questions: why it exists, what it does, and how it is used. If a variable or function requires a comment beside it explaining what it holds, the name has failed.
Functions should do exactly one thing, and do it completely. If your function is called 'handleUserDataAndSendNotificationAndLogMetrics', it is already three functions masquerading as one. Split it.
The Function Length Heuristic
If a function cannot fit on a single screen without scrolling (roughly 25-30 lines), it is almost always juggling multiple responsibilities that deserve their own isolated units.
Banish 'any' and forced type assertions
Using 'any' turns off the TypeScript type checker for that variable and everything it touches. It is the architectural equivalent of disconnecting the fire alarm because you don't like the beep.
Instead of casting with 'as', leverage type narrowing with 'unknown', user-defined type guards, or schema validation libraries like Zod.
The 'as' Assertion Hazard
Type assertions ('x as User') tell the compiler 'trust me, I know better than you.' In 95% of real bugs, the developer did NOT know better than the compiler.
❌Silencing the Compiler with 'any'
// Risky: No compile-time guarantees or autocompletion
function processPayload(payload: any) {
return payload.user.profile.bio.trim(); // Runtime TypeError if user is undefined!
}✅Safe Narrowing with Zod or Type Guard
function processPayload(payload: unknown) {
if (isUserPayload(payload)) {
return payload.user.profile.bio?.trim() ?? "No bio provided";
}
throw new Error("Invalid payload structure received");
}Model state with discriminated unions, not boolean flags
Consider a network request state. Many beginners write four separate boolean flags: isLoading, isError, isSuccess, and isIdle. This creates 16 possible states (2^4), 12 of which are logically impossible (e.g., isLoading=true AND isError=true).
Discriminated unions make impossible states unrepresentable in the type system.
Exhaustive Pattern Matching
When combined with a TypeScript 'switch (state.status)', the compiler forces you to handle every single state and guarantees zero unhandled edge cases.
❌Boolean Flag Explosion (Impossible States)
interface AsyncState {
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
errorMessage?: string;
data?: Project[];
}✅Discriminated Union (Mathematically Sound)
type AsyncState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: string }
| { status: "success"; data: Project[] };Guard clauses over the arrow anti-pattern
Deeply nested if-else statements create the 'Arrow Anti-Pattern'—code that slopes out to the right side of the screen. Your eyes must remember all previous conditions while reading nested blocks.
Invert your conditionals and return early using guard clauses. The 'happy path' stays linear and left-aligned.
The Left Margin Rule
Clean code reads linearly top-to-bottom. The main success path of your function should always hug the left indentation margin.
❌Deeply Nested Arrow Code
function submitInvoice(inv: Invoice) {
if (inv.isValid) {
if (inv.items.length > 0) {
if (user.hasPermission) {
return sendToAccounting(inv);
}
}
}
return null;
}✅Clean Guard Clauses (Early Return)
function submitInvoice(inv: Invoice) {
if (!inv.isValid) return null;
if (inv.items.length === 0) return null;
if (!user.hasPermission) throw new UnauthorizedError();
return sendToAccounting(inv);
}Key Takeaways
Make your types reflect business logic. Name symbols for what they mean, not how they're stored.
Eliminate boolean explosions. Make impossible application states mathematically unrepresentable.
Fail fast and return early to keep your primary business logic flat and human-readable.
Treat 'any' as a critical linter violation. Use 'unknown' and narrow safely with type guards.
Published on Jan 24, 2026 · 10 min read