TypeScript Type Safety: A Practical Guide
Abhay khant
Jan 1, 1970 • 10 min read
TypeScript type safety catches a meaningful share of your bugs before they ship — around 15% of the bugs in real JavaScript projects, by a widely cited University of Waterloo study. But type safety is not automatic. Flip on strict mode, learn a handful of patterns, and the compiler turns from a nag into a net. This guide covers what strict mode actually gives you, the patterns that prevent real-world failures, and how to pair compile-time types with runtime validation.
What type safety buys you
The numbers come from a 2017 study by researchers at the University of Waterloo. They analyzed 400 bugs across 10 open-source JavaScript projects and found that roughly 15% could have been prevented by TypeScript's type checking. The other 85% were outside what static types can express — logic errors, missing feature branches, and wrong business rules. Adoption is broad: 38.5% of developers use TypeScript per Stack Overflow's 2024 survey.
Fifteen percent sounds modest until you count what it costs: those are the bugs that happen before a human ever sees your code. A typo'd property name, a null slipping through, a string where a number belongs — each one is a deploy cycle, a support ticket, or a 3am page. TypeScript moves that whole class of failure earlier, where it is cheap.
The catch: this only works if the types are actually enforced. The default TypeScript config is deliberately loose so it does not break existing projects. You have to opt into the strict rules — and that is the first thing most teams skip.
Strict mode: the real starting point
The TypeScript handbook calls strict the recommended baseline, and the tsconfig reference documents it as a bundle of stronger correctness guarantees. If you take nothing else from this guide, enable strict mode:
// tsconfig.json
{
"compilerOptions": {
"strict": true
}
}
strict is not one flag — it is a bundle of checks that each close a real hole:
| Check | Catches |
|---|---|
strictNullChecks | null and undefined where a value is required |
noImplicitAny | parameters that silently become any |
noUncheckedIndexedAccess | undefined from array and object indexing |
strictFunctionTypes | unsafe function assignment (variance) |
useUnknownInCatchVariables | catch (e) becoming any instead of unknown |
The biggest win is strictNullChecks. Without it, this compiles:
function greet(user: User) {
return `Hello, ${user.name.toUpperCase()}`;
}
greet(null); // Runtime crash: Cannot read properties of null
With it, TypeScript refuses — Argument of type 'null' is not assignable to parameter of type 'User'. The crash that would have happened in production now happens in your editor. That is the entire job of a type system, done correctly.
noUncheckedIndexedAccess is less common but quietly prevents the nastiest class of runtime error:
// strict: true — compiles, but users[i] can be undefined
// strict + noUncheckedIndexedAccess — forces you to handle it
function getUser(users: User[], i: number) {
return users[i].name; // error: users[i] is possibly 'undefined'
}
Enabling the full strict bundle on an existing codebase takes work, but the migration path is well-trodden — more on that at the end.
unknown over any
any is the escape hatch that quietly turns the type checker off. Use it and you lose the 15% — the compiler accepts anything and the crash moves back to runtime:
// BAD: any disables checking everywhere this value flows
function parseConfig(raw: any): Config {
return raw.config;
}
// GOOD: unknown forces you to prove the shape before using it
function parseConfig(raw: unknown): Config {
if (typeof raw !== "object" || raw === null) throw new Error("bad config");
const { config } = raw as Record<string, unknown>;
if (typeof config !== "object" || config === null) throw new Error("no config");
return config as Config;
}
The rule of thumb: unknown for things you do not control (API responses, parsed JSON, catch variables), any for almost nothing. When you must assert a type, use as narrowly and validate what you can first.
Modern TypeScript even lets you ban any mechanically. tsconfig has no built-in noAny, but ESLint's @typescript-eslint/no-explicit-any rule plus a code review rule keeps it from creeping back.
Narrowing: let the type checker do the work
Narrowing is what makes strict types practical — the compiler's narrowing model tracks the types of your variables through guards and checks. Instead of casting, you write checks and TypeScript follows along:
function formatId(id: string | null | undefined): string {
if (id === null || id === undefined) return "unknown";
return id.toUpperCase(); // TS knows id is string here
}
Type predicates let you extract that logic into reusable guards:
function isErrorResponse(res: ApiResponse): res is ErrorResponse {
return "error" in res;
}
if (isErrorResponse(data)) {
data.error.code; // narrowed, no cast needed
}
Discriminated unions are the pattern that makes narrowing scale. Give each variant a literal type field and the compiler can tell them apart across a whole switch:
type ApiEvent =
| { type: "created"; id: string }
| { type: "deleted"; id: string; reason: string }
| { type: "error"; message: string };
function handleEvent(event: ApiEvent) {
switch (event.type) {
case "created":
return event.id;
case "deleted":
return `${event.id}: ${event.reason}`;
case "error":
return event.message;
}
}
The payoff is exhaustive checking. Add a fourth variant to ApiEvent and TypeScript flags the switch as missing a case. The compiler becomes a living checklist that refuses to let you forget.
satisfies and const assertions
satisfies (introduced in TypeScript 4.9) checks that a value matches a type without widening it to that type. You keep the precise literal types while proving the shape:
const routes = {
home: "/",
user: (id: string) => `/users/${id}`,
} satisfies Record<string, string | ((...args: unknown[]) => string)>;
routes.user("42"); // keeps its callable type, still type-checked
as const freezes an object's types down to their literals — useful for configuration and lookup tables:
const roles = ["admin", "user", "viewer"] as const;
type Role = (typeof roles)[number]; // "admin" | "user" | "viewer"
Combined, they give you type-safe configuration without repeating yourself.
Generics: types that compose
Generics let one function serve many shapes while keeping the types tight. The classic is a safe accessor:
function getOrDefault<T>(obj: T, key: keyof T, fallback: T[keyof T]): T[keyof T] {
return obj[key] ?? fallback;
}
const config = { retries: 3, host: "api.example.com" };
getOrDefault(config, "retries", 0); // number
getOrDefault(config, "host", ""); // string
keyof keeps the key and value types locked together — getOrDefault(config, "nope", 0) fails to compile. When the types of arguments relate, generics express the relationship instead of falling back to any.
Compile-time is not runtime
Here is the honest limitation: TypeScript's types exist at compile time only. They are erased when the code runs. So this compiles, type-checks, and then crashes on the wrong input at runtime:
function saveUser(user: User) {
// user.name is typed as string...
// ...but the HTTP body it came from was never checked
}
fetch("/api/user")
.then((r) => r.json())
.then(saveUser); // runtime: user.name could be undefined
The type system trusts your fetch wrapper. Whatever JSON arrives, you declared it User, so the compiler believes you. That trust is exactly where runtime validation comes in.
Runtime validation: Zod and the single source of truth
Zod (and smaller alternatives like Valibot) define your data model once as a schema, then derive both the runtime validator and the TypeScript type from it:
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(100),
roles: z.array(z.enum(["admin", "user", "viewer"])),
});
type User = z.infer<typeof UserSchema>; // the compile-time type
const parsed = UserSchema.safeParse(await response.json());
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error.issues });
}
saveUser(parsed.data); // User — verified at runtime, not just trusted
One definition, two guarantees: the schema validates whatever crosses the trust boundary, and the inferred type flows through your codebase. Rename a field in the schema and both the validator and every consumer update together — this is the "single source of truth" approach that kills the drift between your TypeScript interfaces and your runtime checks.
The same schema exports to JSON Schema, so one model can drive validation in your API, documentation, and other languages. Our JSON Schema generator guide covers that path in depth, and the Zod to JSON Schema walkthrough shows the export step end to end.
A note on choice: Zod is the default — mature, heavily used, and well documented. Valibot is a lighter alternative worth a look if bundle size matters. For most teams the schema library matters less than the discipline of actually validating at the boundary.
This boundary discipline is what turns an API contract from documentation into enforcement. We go deeper on testing the agreement between your services in our API contract testing guide. You can also paste a sample payload against your schema in the ToolSura JSON validator to see how runtime validation behaves before you wire it in.
Migrating an existing JavaScript project
You do not have to flip everything to strict in one day. The well-trodden path:
- Get it compiling. Add TypeScript with loose settings (
allowJs: true,checkJs: false), get the build green, and keep shipping. - Turn on
noImplicitAny. Fix the errors — this is the bulk of the work. Most are cases where a parameter was never typed. - Turn on
strictNullChecks. The hard part: nownullandundefinedare real. Use?for genuinely optional fields and explicit handling everywhere else. - Flip
strict. With the first two done, the remaining strict flags are usually quick wins. - Ban
any. Addno-explicit-anyand reserve it for genuinely unknown external data — then replace those withunknownplus validation.
Teams routinely report the migration surfacing bugs that had been lurking for years — undefined reads, wrong field names, misused option objects. That is the 15% arriving all at once.
Is it worth it for small projects?
Yes, with one caveat. The cost of strict typing is front-loaded: you write more annotations early and the compiler nags while the types settle. The payoff compounds the moment the project grows — refactors, new teammates, and third-party data all get safer. For a throwaway script or a prototype you will delete, skip the ceremony. For anything with a future, strict TypeScript with Zod at the boundaries is the cheapest insurance you will buy.
Related Tools & Further Reading
- Export schemas and keep validators in sync with the JSON Schema generator guide
- See the export step with the Zod to JSON Schema walkthrough
- Test the contracts your types describe with the API contract testing guide
- Try validation against a live payload in the ToolSura JSON validator
References
[1] University of Waterloo study on TypeScript and JavaScript bugs (ICSE 2017) — "To Type or Not to Type: Quantifying Detectable Bugs in JavaScript" — https://earlbarr.com/publications/typestudy.pdf
[2] TypeScript Handbook: The TypeScript Handbook — https://www.typescriptlang.org/docs/handbook/intro.html
[3] TypeScript 4.9 Release Notes (satisfies) — https://devblogs.microsoft.com/typescript/announcing-typescript-4-9/
[4] Zod documentation — https://zod.dev/
[5] Valibot — https://valibot.dev/


