TypeScript Best Practices for Maintainable Code
TypeScript is most useful when it helps model reality, not when it turns every change into a puzzle. A few defaults can make a codebase calmer, safer, and easier to refactor.

Start strict
Enable strict type checking at the beginning of a project. In an existing JavaScript codebase, migrate in controlled sections rather than weakening the destination.
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true } }
strictNullChecks, included by strict, catches a particularly common class of bugs: assuming a value exists because it existed in the happy-path fixture.
noUncheckedIndexedAccess is noisier because items[index] becomes T | undefined. That noise is often useful at external or user-controlled boundaries. A team can introduce it separately if enabling every stricter option at once would overwhelm a mature codebase.
Prefer readable types
A type should make the program easier to understand. If a conditional type takes longer to decode than the function it protects, a simpler model may be better.
Use object types or interfaces for stable shapes:
interface ProjectSummary { slug: string; title: string; publishedAt: string; }
Use unions when a value can be one of several meaningful states:
type RequestState = | { status: "idle" } | { status: "loading" } | { status: "success"; projects: ProjectSummary[] } | { status: "error"; message: string };
The model prevents impossible combinations such as loading: true alongside stale error and success values.
Memorize discriminated unions
Discriminated unions are one of TypeScript’s most practical patterns. A shared literal field lets the compiler narrow each branch:
type Result<T> = | { ok: true; value: T } | { ok: false; error: string }; export function parseNumber(input: string): Result<number> { const value = Number(input); return Number.isFinite(value) ? { ok: true, value } : { ok: false, error: "Not a number" }; }
const result = parseNumber(userInput); if (result.ok) { console.log(result.value); } else { console.error(result.error); }
There is no assertion and no optional property to forget. The data itself explains which properties are available.
Use unknown at uncertain boundaries
unknown means a value has not been validated yet. It prevents property access until the program proves the shape. any turns type checking off and allows uncertainty to spread through every caller.
External JSON, environment variables, message payloads, and storage values should be treated as untrusted inputs:
type UserResponse = { id: string; name: string; }; function isUserResponse(value: unknown): value is UserResponse { if (typeof value !== "object" || value === null) { return false; } const candidate = value as Record<string, unknown>; return ( typeof candidate.id === "string" && typeof candidate.name === "string" ); }
For larger schemas, a runtime validation library can produce both validation and an inferred type. The important boundary remains the same: TypeScript types disappear at runtime, so external data needs runtime checks.
Avoid broad type assertions
as SomeType tells the compiler to trust the programmer. It does not validate the value. Assertions are sometimes necessary around browser APIs or imperfect library definitions, but they should stay close to the evidence that justifies them.
Prefer:
- narrowing with
typeof,in, orinstanceof; - a user-defined type guard;
- a schema parser;
- the
satisfiesoperator for checking without widening; - or a small adapter around an untyped library.
const routes = { home: "/", projects: "/projects", writing: "/blog", } satisfies Record<string, `/${string}`>;
Here the object keeps its precise keys and values while still being checked against the expected route format.
Make impossible states hard to express
Avoid collections of loosely related booleans:
type DialogState = { isOpen: boolean; isSaving: boolean; hasError: boolean; };
This permits combinations the interface may not support. A state union describes the actual lifecycle more clearly:
type DialogState = | { state: "closed" } | { state: "editing" } | { state: "saving" } | { state: "error"; message: string };
This pattern is useful for network requests, payment flows, authentication, and any process with named stages.
Preserve exhaustiveness
When every variant must be handled, let the compiler prove it:
function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`); } function getStatusLabel(state: RequestState): string { switch (state.status) { case "idle": return "Ready"; case "loading": return "Loading"; case "success": return `${state.projects.length} projects`; case "error": return state.message; default: return assertNever(state); } }
Adding a new variant now creates a compile-time reminder wherever complete handling is required.
Keep suppressions accountable
Prefer @ts-expect-error over @ts-ignore. The former becomes an error when the underlying type problem disappears, so obsolete suppressions are easier to remove.
Add a short reason:
// @ts-expect-error — upstream types omit the supported "compact" mode. configureWidget({ mode: "compact" });
A suppression without an explanation tends to become permanent mystery. Better still, isolate the mismatch in an adapter and link the upstream issue.
Let inference do routine work
Type annotations are most useful at public boundaries: exported functions, component props, API inputs, and persistent data. Local values often read better when TypeScript infers them.
const visibleProjects = projects.filter((project) => project.isPublished);
Repeating the obvious type adds maintenance without adding information. Annotate when it documents intent or prevents an unwanted widening.
Tooling as a feedback loop
Run type checking independently from the production build so errors are fast and visible in CI. Add TypeScript-aware lint rules selectively; a smaller set the team trusts is better than hundreds of noisy warnings.
Type tests can also protect public libraries. They verify that expected usage compiles and invalid usage does not, covering a contract that runtime tests cannot see.
Wrap-up
The best TypeScript code reads like documentation: clear names, explicit states, narrow boundaries, and errors that point toward a fix. Start strict, validate external data, prefer unknown over any, and reach for discriminated unions before collections of optional fields.
Types should make change safer. When they make ordinary work harder, simplify the model before adding more cleverness.
