After a
file-by-file leap to TypeScript,
teams usually inherit a tsconfig that "works" and a vague mandate to "be
strict." Strict is not one switch with one personality. It is a bundle of
checks, plus a few optional flags that hurt more than the bundle, and a
toolchain that does not always honor the same file the same way.
What strict: true actually turns on
As of the TypeScript 5.x line I am writing against, strict enables a set of
flags including (among others) strictNullChecks, strictFunctionTypes,
strictBindCallApply, strictPropertyInitialization, noImplicitAny,
noImplicitThis, and useUnknownInCatchVariables. Exact membership can shift
across majors, read your version's docs when upgrading, but the day-to-day pain
is predictable.
| Flag people feel | What breaks first | When I insist |
|---|---|---|
strictNullChecks | Optional props treated as always present; API JSON assumed complete | Always for app code |
noImplicitAny | Untyped parameters, empty callbacks, JSON.parse results | Always |
strictFunctionTypes | Callback variance bugs in props and event handlers | Always |
strictPropertyInitialization | Class fields set in componentDidMount-era patterns | Always; prefer definite assignment sparingly |
useUnknownInCatchVariables | catch (e) used as e.message without narrowing | Always |
Turning strict off to "finish the migration" is how six months later nobody
remembers which holes were intentional.
Extras that are not in strict (and bite harder)
These are the ones I schedule deliberately after the leap:
noUncheckedIndexedAccess
tsconst map: Record<string, number> = { a: 1 }; const value = map['b']; // without flag: number // with flag: number | undefined
Arrays and index signatures suddenly return T | undefined. That is correct and
annoying. React lists that did items[i].id without a guard light up.
When I flip it: shared domain packages and anything touching money, permissions, or routing params. When I delay: greenfield UI spikes where the noise delays shipping and tests already cover the empty cases. I do not leave it off forever in product cores.
exactOptionalPropertyTypes
tstype Props = { title?: string }; // With the flag, `{ title: undefined }` is not the same as `{}`. const a: Props = {}; // ok const b: Props = { title: undefined }; // error under exactOptionalPropertyTypes
This catches real bugs when optional means "omit" but callers pass explicit
undefined from spreads. It also fights common React patterns like
ts<Button {...(disabled ? { disabled: true } : {})} />
versus spreading { disabled: maybeUndefined }.
When I flip it: API client types and shared component libraries. When I wait: app folders mid-migration with heavy prop spreading.
noPropertyAccessFromIndexSignature
Forces obj['key'] for index signatures instead of obj.key. Good for
dictionary types; noisy for loosely typed config objects. I enable it in
packages where "stringly keys" are the product.
Toolchain: three readers of one config
A surprising amount of "TypeScript is wrong" is three tools disagreeing.
tsc --noEmitin CI: the source of truth for the repo's type gate. If CI is green and the editor is red, trust CI first, then fix editor project loading.- The editor (TS language service): may load a different
tsconfigvia solution-style roots, a nested package config, or a stale server. "Restart TS server" is not superstition; wrong project graph is common in monorepos. - The bundler (Vite, etc.): uses esbuild/SWC for transpile. It does not
enforce your strict flags. Shipping can succeed while
tscfails, or the reverse if you skiptscin CI.
So: bundler for speed, tsc for truth. Dropping the CI typecheck because
"Vite built fine" is how any returns through the side door.
Project references and paths without self-harm
Monorepo sketch that stays honest:
json// apps/web/tsconfig.json { "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, "rootDir": "src", "paths": { "@/*": ["./src/*"] } }, "references": [{ "path": "../../packages/ui" }], "include": ["src"] }
Rules I keep:
pathsare for the package that owns them, mirrored in the bundler alias. Apathsentry with no Vite/Webpack equivalent is editor cosplay.- Prefer project references over one giant root
includeonce packages multiply. One root that typechecks the universe makes every edit wait on the world. skipLibCheck: trueremains the default for app repos, checking all ofnode_modulestypes is rarely the bug you meant to catch. Library authors publishing types should still care about their own emit.
Failure modes
Strict in base, loose in app overrides. A package extends the strict base
then sets "strict": false "temporarily." Temporary becomes culture. If an app
needs a narrower exclude list, exclude files, do not disable strict.
// @ts-expect-error without a ticket. Expect-error is a scalpel. Require a
comment that names the bug or the upstream type gap. Bare suppressions are how
maps of shame form.
Different jsx / moduleResolution per app for no reason. Copy-paste
configs drift. Extend a base; override only what the runtime requires (jsx for
React, nodenext for a CLI package).
Believing incremental fixes architecture. Incremental helps; it does not
replace splitting projects or deleting dead include globs.
A practical rollout order
strict: truegreen on the pilot package.- Turn on
noUncheckedIndexedAccessfor domain/shared packages; fix call sites. - Evaluate
exactOptionalPropertyTypeson API types before UI props. - Align Vite aliases with
paths; add a CI step that runstsc -b(or per-packagetsc --noEmit) on every PR. - Only then argue about niche flags.
Takeaway
strict is the floor. The extras, especially noUncheckedIndexedAccess, are
where production data shapes stop lying. The bundler will not save you; wire
tsc as a CI gate and treat editor/CI skew as a project-graph bug, not a type
theory debate.
Next arc shift: SPA vs MPA trade-offs, navigation, SEO, data, and team shape - before we pick Vite as a concrete SPA baseline.