You now know the TypeScript language. This final lesson is about using it well: the habits that make types an asset instead of noise, and the mistakes that quietly erode the safety you adopted TypeScript for. Treat this as a checklist to revisit as your projects grow.
Everything in this series assumes "strict": true. Without it, null checks vanish and implicit any spreads through your code. If you inherit a non-strict codebase, enable flags incrementally (noImplicitAny first, then strictNullChecks) rather than living without them. Add noUncheckedIndexedAccess for arrays and dictionaries once the team is comfortable.
any silently disables checking for a value and everything derived from it. Nearly every use has a better alternative:
// Bad: nothing is checked
function handle(payload: any) { return payload.user.name; }
// Good: unknown forces validation
function handleSafe(payload: unknown) {
if (
typeof payload === "object" && payload !== null &&
"user" in payload
) {
// narrowed step by step, or better: validate with a schema library
}
}When any is genuinely unavoidable (untyped third-party code), quarantine it in one small wrapper function with a typed signature so it cannot leak.
as tells the compiler "trust me" — it changes nothing at runtime and can be flatly wrong:
const data = JSON.parse(raw) as UserProfile; // hope-driven developmentIf the JSON does not match UserProfile, you get runtime crashes with a green build. Prefer narrowing, custom type guards, or schema validation (Zod, Valibot) at every boundary where data enters your program: HTTP responses, file reads, message queues, localStorage. Inside those boundaries, honest types flow without assertions. Reserve non-null ! for cases you can prove, and consider a thrown error with a clear message instead.
Redundant annotations add noise and can even mask mistakes:
// Noisy
const names: string[] = ["Ada", "Grace"].map((n: string): string => n.trim());
// Clean — identical types, all inferred
const names2 = ["Ada", "Grace"].map(n => n.trim());Annotate where types are contracts: exported function signatures, public APIs, and empty initializations. Let inference handle locals and callbacks.
When two hand-written types describe overlapping data, they will drift apart. Derive instead:
interface User { id: number; name: string; email: string }
type UserUpdate = Partial<Omit<User, "id">>; // derived, stays in sync
type UserRow = User & { createdAt: Date };
const ROLES = ["admin", "member"] as const;
type Role = (typeof ROLES)[number]; // one source of truthUtility types, keyof, typeof, and as const exist precisely so a change in one place propagates everywhere.
Precise types prevent invalid states from being representable:
// Weak: any string accepted, flags can contradict each other
function fetchData(url: string, isLoading: boolean, hasError: boolean) {}
// Strong: a discriminated union — impossible states are impossible
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };A switch on status with a never exhaustiveness check then guarantees every state is handled — and keeps guaranteeing it as states are added.
readonly T[] parameters; return concrete types.tsc --noEmit in CI so type errors block merges even when a bundler builds the app.@ts-ignore; if you must, use @ts-expect-error with a comment — it errors when the underlying problem is fixed.@types versions cause phantom errors.noUncheckedIndexedAccess when ready.any with unknown plus narrowing; validate all external data at the boundary.as as a code smell to justify.Congratulations — you have completed the CodeYourCraft TypeScript series! Revisit any lesson as a reference, and start applying these patterns in your next project.