43 β Type-Safety Discipline and Common Traps
Level: Advanced Β· Time: ~35 min Β· Prerequisites: Lesson 42 β Testing with Vitest
Why this matters
Every as, every ! and every @ts-ignore is a promise you make to the compiler: trust me. The compiler cannot check a promise β that is exactly what the escape is for β so a type checker is not a property of your code but a habit you keep or lose. A team that reaches for these shortcuts a few times a day still arrives at the end of the year with a repository that type-checks and still fails in production, having paid full price for TypeScript and received none of the benefit. This lesson is the discipline that keeps the checker honest: what each escape really does to a type, which strict flags close which hole, how to read a compiler error rather than muffle it, and how to bring an existing JavaScript project across one file at a time.
The four escapes, and what each one does to the type
any is not "a type I do not know" β it is a switch that turns checking off, and its effect spreads: every value derived from an any is any as well, so one careless signature infects every caller that touches it. unknown is the honest version of the same idea: a value whose type is genuinely unknown, which you must prove before you use it. as asserts and checks almost nothing β from JSON.parse's any the compiler will happily accept as ProductionDay, and it will accept as string too; only genuinely unrelated types are rejected, and the workaround for those (as unknown as T) is a confession rather than a fix. ! deletes undefined from the type and changes nothing at runtime. @ts-expect-error silences one line and fails the build the day the error stops existing (TS2578), which makes it a ratchet; @ts-ignore silences one line silently, forever, including errors it never saw.
type ProductionDay = { readonly date: string; readonly region: string; readonly kwh: number };
const text = '{"date":"2026-03-14","region":"Dakar","kwh":18.4}';
const byRegion = new Map<string, number>([["Dakar", 18.4]]);
export function isProductionDay(value: unknown): value is ProductionDay {
if (typeof value !== "object" || value === null) return false;
const { date, region, kwh } = value as Record<string, unknown>;
return typeof date === "string" && typeof region === "string" && typeof kwh === "number";
}
// The escapes: the compiler is satisfied, the data was never inspected
const day = JSON.parse(text) as ProductionDay;
const thiesKwh: number = byRegion.get("Thiès")!;
// The same program with the claim verified instead of asserted
const value: unknown = JSON.parse(text);
if (!isProductionDay(value)) throw new Error("Unexpected solar payload");
const checked: number = value.kwh;
// A typed JSON.parse wrapper, for when ten call sites parse the same shape
export function parseJson<T>(parsedUnknown: string, guard: (candidate: unknown) => candidate is T): T {
const parsed: unknown = JSON.parse(parsedUnknown);
if (!guard(parsed)) throw new Error("Unexpected JSON shape");
return parsed;
}
The guard in that block contains one cast of its own, as Record<string, unknown>, and it is the acceptable kind: after a runtime check for a non-null object, the cast names the only thing TypeScript cannot infer from the check, and the values inside stay unknown β unknown values, verified one by one with typeof. A cast to ProductionDay at the same spot would skip all four verifications.
| Escape | What it does to the checker | The honest replacement |
|---|---|---|
any |
Switches checking off for the value and everything derived from it | unknown plus a type guard (see Lesson 8) |
as T |
Asserts a type the compiler cannot verify β even when the data disagrees | A guard, or a schema parse that returns parsed.data (see Lesson 30) |
! |
Removes undefined from the type only; the runtime value is untouched |
?? with a default, an early return, or an explicit throw |
@ts-expect-error |
Silences the next line and fails when the error disappears: a ratchet | Keep it for one release, always with a description of why |
@ts-ignore |
Silences the next line silently, forever β including future errors | Nothing. It has no legitimate use in application code |
The strict flags that close the holes
"strict": true is the setting every project should have, and it is not sufficient. It bundles noImplicitAny, strictNullChecks, useUnknownInCatchVariables and the rest of the null-safety family β but three of the flags that catch the traps left behind by the escapes above are not part of it, and every migration that stops at strict keeps them switched off by accident:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true
}
}
| Flag | Inside strict? |
The specific trap it catches |
|---|---|---|
noImplicitAny |
yes | A parameter or return the compiler had to guess: function add(a, b) is an error, not a silent any |
strictNullChecks |
yes | Using a value that may be null or undefined without handling it first |
useUnknownInCatchVariables |
yes | catch (error) typing the error as any, so error.message on a thrown string is unchecked (see Lesson 20) |
noUncheckedIndexedAccess |
no | rows[0], record[key] and destructured elements typed as T \| undefined instead of T |
exactOptionalPropertyTypes |
no | Passing { limit: undefined } to limit?: number, which is not the same as omitting the key |
noImplicitOverride |
no | A subclass method silently replacing a base method without the override keyword (TS4114) |
Turning on noUncheckedIndexedAccess in a mature project produces a wall of TS18048 errors, and that wall is the point: each one is an index access that was never proved to be in range. Fix them with a guard or a default β const row = rows[0]; if (!row) return; β not with rows[0]!, which restores the previous silence with extra punctuation.
Reading a compiler error instead of silencing it
An error message always names two types, and your job is to decide which one is wrong. Read the caret position, the type shown there, and the type it was expected to be; the disagreement is between the last two, and one of them is a lie β often the wider one, because a value came from a boundary. Then fix the real end: narrow the value with a guard, widen the parameter if it was over-constrained, or handle the case the compiler is pointing at. The message wording is a precise signal:
| Code | A real message from this course | What it means and where to look |
|---|---|---|
| TS2322 | Type 'string \| undefined' is not assignable to type 'number \| undefined' |
Generic inference chose T = string from firstOf(["18.4"]); annotate the call or the helper's parameter |
| TS2345 | Argument of type 'string' is not assignable to parameter of type 'number' |
The call site passes the wrong type; read the parameter's declaration, not the argument's |
| TS2339 | Property 'region' does not exist on type '{ date: string; kwh: number }' |
A misspelt or genuinely absent field β the typo class from Lesson 1, now caught |
| TS18048 | 'row' is possibly 'undefined' |
A case is unhandled: if (!row) return; is the fix, ! is the silence |
| TS18046 | 'raw' is of type 'unknown' |
A value from a boundary; narrow it instead of casting it |
| TS7006 | Parameter 'value' implicitly has an 'any' type |
No annotation and nothing to infer from: annotate it and the error becomes a check |
| TS2366 | Function lacks ending return statement and return type does not include 'undefined' |
The union has a case you forgot β the exhaustive switch from Lesson 8 makes the compiler list them |
Declaration files, @types packages and libraries with no types
Types reach your project as .d.ts declaration files, resolved through the package's types field or its exports map, plus the community packages you install as @types/<name> (@types/node is the one every project needs). When a package ships nothing at all, you have three lawful options and no fourth: look for an @types package first, write a small ambient shim if the package is genuinely untyped, or replace the dependency. Silencing the import with any is the fourth option and the one that costs the most.
// src/types/legacy-solar-cli.d.ts β an ambient shim for a package that ships no types
declare module "legacy-solar-cli" {
export function loadDays(path: string): Promise<unknown[]>;
export const version: string;
}
// src/types/env.d.ts β module augmentation: adding to types somebody else declared
declare global {
namespace NodeJS {
interface ProcessEnv {
readonly SOLAR_DB_PATH?: string;
readonly NASA_POWER_BASE_URL?: string;
}
}
}
export {};
The shim is a contract, so keep it as narrow as the truth: Promise<unknown[]> forces every caller to validate what the legacy tool returned, and that is a feature. The augmentation cannot change a declared type, only add to it; declare global must live in a module, hence the export {} that stops the file from being treated as a global script. Both files need to be inside the include of your tsconfig.json before anything changes.
Migrating a JavaScript project without a rewrite
Nobody migrates a real project in one commit. The safe order is: let the compiler read JavaScript without checking it, check one file at a time, then rename files from the leaves upwards β the modules with no imports of their own first, so a renamed file can rely on already-typed dependencies instead of any. Start with only the flag that catches the noisiest real bugs:
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"strict": false,
"noImplicitAny": true
}
}
With checkJs off, only files carrying // @ts-check at the top are checked, so you can opt in one file per afternoon and keep a green build all week. Add // @ts-check to a legacy file with an unannotated parameter and the compiler answers with error TS7006: Parameter 'value' implicitly has an 'any' type β one line at a time, no rewrite. Then climb the ladder in this order, one step per pull request: noImplicitAny, then strictNullChecks, then strict: true, then the three extra flags above. Each step is a fixed set of errors and a permanently better project; several at once is a fortnight of merge conflicts.
[!TIP] Use
@ts-expect-erroras the migration ratchet and never@ts-ignore. Both silence the line, but an unused@ts-expect-erroris itself an error (TS2578), so the count of exceptions can only go down β and a grep for the directive gives you the exact list of remaining compromises in one command.
Lint, format and the review habit
The compiler is not the only reader of your code. ESLint with typescript-eslint in type-checked mode catches the escapes as they are typed, Prettier removes all formatting arguments, and CI runs both so the two disciplines survive the busy weeks:
// eslint.config.js
import tseslint from "typescript-eslint";
export default tseslint.config(
...tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/ban-ts-comment": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
},
);
Install with npm install --save-dev eslint typescript-eslint prettier, then npx eslint . and npx prettier --check .. Two of those rules produce the messages worth memorising: Unexpected any. Specify a different type from a parameter typed any[], and Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free. In CI, one line covers the whole discipline: npm ci && npm run typecheck && npx eslint . && npx vitest run.
Reviewing TypeScript is a different exercise from reviewing prose. Read the signatures first β parameter and return types of every exported function, the shape of every DTO β because that is the contract the rest of the codebase is compiled against, and a widened return type is a change nobody's editor will complain about. Then check the boundaries: every value arriving from JSON.parse, fetch, a file or process.env is unknown until a guard or schema proves otherwise. Finally, treat every assertion in a diff as a question: as, ! and @ts-expect-error should each have an answer in the review comments, or be replaced by the checking version of themselves.
Common mistakes
| Mistake | What happens | The fix |
|---|---|---|
| Casting instead of validating a boundary value | as ProductionDay over unchecked JSON compiles and crashes on the first missing field |
Parse with a guard or schema and let the parsed value be typed (see Lesson 30) |
Using ! to clear a "possibly undefined" warning |
The warning disappears and the runtime TypeError stays; byRegion.get("Thiès")! is still undefined |
Handle the case: ?? with a default, an early return, or an explicit throw |
Typing an escape hatch as any |
The safety net vanishes exactly where the risk is, and callers inherit the any |
unknown at the boundary plus a narrowing guard |
Reaching for @ts-ignore because it always works |
It also hides the next error on that line, and hides nothing at all if the bug moves | @ts-expect-error with a description: it fails when the error goes away (TS2578) |
| Leaving the three extra strict flags off | Index, optional-property and override traps survive a project that claims "strict": true |
Enable noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride |
Renaming every .js to .ts in one commit |
Hundreds of errors, no green build for a week, and the migration is abandoned | allowJs plus // @ts-check per file, then rename leaves first |
Silencing a library you cannot type with any |
Every call site loses its types and one bad shim infects the domain model | A narrow ambient shim, an @types package, or a different dependency |
| Copying a type instead of importing it | Two descriptions of one payload drift, and the compiler defends the wrong one | One source of truth: import type, or a type derived with z.infer |
Try it
-
In
ts-zero-to-hero, createsrc/lie.tswith two silent lies:const day = JSON.parse('{"date":"2026-03-14","region":"Dakar"}') as ProductionDay;followed byconsole.log(day.kwh.toFixed(1));, andconst thies = new Map([["Dakar", 18.4]]).get("Thiès")!;followed byconsole.log(thies.toFixed(1));. -
Run
npm run typecheck, thennpx tsx src/lie.ts.
Expected: the type check prints nothing and exits 0 β the compiler is completely satisfied. Node then fails with TypeError: Cannot read properties of undefined (reading 'toFixed') and a non-zero exit code. Two commands are the whole lesson: the checker was silent, the runtime was not.
-
Fix both without a single
asor!: a guard for the parsed object,?? 0for the map lookup. Expected:npm run typecheckstill silent, andnpx tsx src/lie.tsnow prints18.4and0.0. -
Add
noUncheckedIndexedAccess,exactOptionalPropertyTypesandnoImplicitOverridetotsconfig.json, runnpm run typecheck, and repair what appears. Expected: TS18048 at any unguardedrows[0], TS2375 wherever a key is set toundefinedinstead of omitted, TS4114 on overriding methods β and every fix is a handled case, not an assertion. -
Add
src/types/legacy-solar-cli.d.ts(the shim) andsrc/types/env.d.ts(theProcessEnvaugmentation), then confirmprocess.env.SOLAR_DB_PATHisstring | undefinedwith no error. Finish withnpm install --save-dev eslint typescript-eslint prettier,npx eslint .andnpx prettier --check ..
Expected: npx eslint . reports Unexpected any. Specify a different type for every remaining any and zero problems once they are replaced with unknown and guards.
Key takeaways
- The checker is only as honest as your escapes:
anyspreads,asasserts without checking,!deletes a case from the type while leaving it in the program. unknownplus a guard and a typedparseJsonhelper are the replacements that keep the same convenience with the verification intact."strict": trueis necessary and not sufficient βnoUncheckedIndexedAccess,exactOptionalPropertyTypesandnoImplicitOverrideare the three flags that catch what escapes leave behind.- Compiler errors name two types, and one of them is wrong; read the code, decide which end is a lie, and fix that end rather than the message.
- Migrate JavaScript leaf-first with
allowJs,checkJsand// @ts-checkper file, climbing the flag ladder one step per change, and never as one large rename.
Check yourself
- A colleague replaces
value as ProductionDaywithif (!isProductionDay(value)) throw β¦. Both versions compile. What is the difference on the day the API renames a field? - Why does
@ts-expect-errormake a better migration marker than@ts-ignore, given that they silence exactly the same line? "strict": trueis on,rows[0].kwhcompiles, androwscan be empty. Which flag would have caught this, and what are three ways to fix it that do not involve!?- Your project's library offers no types. Write the three acceptable options in order of preference, and say what each one costs.