Definition
The environment behind a type contract is the set of compiler options and versions required for the verdict "this code doesn't compile" to hold.
Here's the core of it. "It doesn't compile" isn't a property of the code — it's a function of tsconfig. The same file gets rejected in one repo and passes in another. So if you want to claim you blocked something with types, you have to pin down which environment produced that verdict too.
Why you need it
Say you built a type contract and announced, "now this kind of bad code won't compile." Whether that's true depends on your settings.
function greet(name: string) {
return name.toUpperCase();
}
greet(null); // will this be blocked?
If strictNullChecks is off, it passes. null is assignable to every type. No matter how carefully you craft the contract, without the premise it all means nothing.
There are plenty of subtler cases too.
const config: Record<string, string> = {};
config.apiUrl.length; // passes without noUncheckedIndexedAccess
type Options = { retry?: number };
const o: Options = { retry: undefined }; // passes without exactOptionalPropertyTypes
So in any repo where you build type contracts, check and record the environment once per repo. Not per card, not per task — once.
How it works
The effective values are what count
What you see in the tsconfig.json file isn't the actual configuration, because it gets merged along the extends chain.
# Look at what actually applies, not what's written in the file
pnpm exec tsc --showConfig
The only way to know what sits behind that one line of extends: "@company/tsconfig/base" is this command. The same goes when a framework ships its own base config (Next.js and friends).
The compiler version that counts is the one actually resolved
pnpm exec tsc --version
Even if package.json says ^5.4.0, the verdict rests on what the lockfile actually installed. A result that only passes in the TypeScript Playground or on the latest version is not evidence. It's also common for your editor's version (VS Code's bundled TypeScript) to differ from the project's.
What to check
| Item | Requirement | What weakens if unmet |
|---|---|---|
| TypeScript version | ≥ 5.4 | You can't use NoInfer (5.4), const type parameters (5.0), or satisfies (4.9) |
strict | Required | Without union narrowing and null safety, the contract has no premise at all |
strictFunctionTypes | Recommended | Callback parameter checking goes loose (the method bivariance exception remains even when on) |
useUnknownInCatchVariables | Recommended | catch values flow around like any |
noUncheckedIndexedAccess | Recommended | Array index and lookup access passes with no undefined check |
noPropertyAccessFromIndexSignature | Recommended | Open dictionary keys get read like guaranteed properties |
exactOptionalPropertyTypes | Recommended | The undefined distinction between "keep vs. delete" isn't guaranteed |
strict is a bundle of several flags. Some repos keep strict: true while turning individual flags off, so look at the effective values, not the bundle name.
Applying it in practice
Verdicts and what to do
Everything met — record the tsconfig location and TypeScript version, then move on. Don't re-verify in later work.
strict or the version unmet — stop here. Don't quietly change tsconfig. It's a policy change that ripples through the whole repo, and the moment you turn it on, hundreds of existing files may light up with errors. Write up the unmet items and their blast radius, and hand the decision to a human.
A recommended flag unmet — propose turning it on, but if that's rejected or deferred, record the list of contracts that weaken and move on. It means "the compiler won't catch this; review and tests have to."
What to write down
## Type environment (verified 2026-08-24)
- TypeScript: 5.6.3 (as resolved by the lockfile)
- Effective tsconfig values: per `tsc --showConfig`
- strict: true
- strictFunctionTypes: true
- noUncheckedIndexedAccess: true
- exactOptionalPropertyTypes: **false** ← unmet
- Weakened contract: the "keep vs. delete" distinction in the operation union isn't guaranteed at compile time
→ runtime tests own those mutation paths
Upgrading the compiler is a policy change
Bumping the TypeScript version isn't a plain tool update. When assignability rules and strict-family behavior change, code that used to pass can get rejected, or the other way around. If a diff shows a version different from the one you recorded, redo this check and update the record.
When you need performance evidence
Introducing recursive types or distributive conditional types can noticeably increase compile time. When that happens, don't hardcode numeric limits into a document — judge it with the actual compiler diagnostics for your project.
pnpm exec tsc --noEmit --extendedDiagnostics
Compare the before and after values and record them. Use --generateTrace only once a regression has actually been observed and you need to find its cause.
Trade-offs
There's a one-time verification cost. It's running two commands and filling in a table, so ten minutes covers it — but when you're in a hurry, you'll want to skip it.
Here's what happens if you skip it: you put real effort into a type contract, and later you discover strict was off. Everything you believed you'd "blocked" was never blocked. That's expensive to undo.
Turning flags on is more expensive. Enabling noUncheckedIndexedAccess alone can produce hundreds of errors in existing code. So the realistic compromise isn't "always turn on any recommended flag that's off" — it's propose turning it on, and if that's rejected, record what weakens.
When not to use it
- Repos where you aren't building type contracts. Applying this procedure to scripts or prototypes just adds ceremony.
- Repeating it every task. It's once per repo. Revisit only in diffs that change tsconfig or the TypeScript version.
- Editing tsconfig without human approval on the grounds of "fixing the environment." Never do this. The blast radius is the entire repo.
Common mistakes
- Judging from the
tsconfig.jsonfile alone. Drawing conclusions without knowing what sits behind theextendschain. - Checking against the editor. If VS Code's bundled TypeScript version differs from the project's lockfile version, you get red squiggles while CI passes, or the reverse.
- Verifying in the Playground. The Playground is always the latest version with default settings. It can't serve as evidence for a verdict about your repo.
- Relaxing because you saw
strict: true. Individual flags may be off behind it. - Quietly turning on an unmet flag. You surprise your reviewer along with hundreds of errors.
- Not recording the weakened contracts. Not being able to turn a flag on is fine; the problem is nobody knowing what that leaves unblocked.
Related concepts
- type-level-testing — pinning down "what doesn't compile" in code, on top of this environment
- false-type-contracts — contracts that become false because a flag is off
- typescript-variance — the hole that remains even with
strictFunctionTypeson