strict: true is table stakes. It is eight flags, not the whole fence. arr[i] is still T. A missing break still falls through. An enum still emits a value.
This note follows Kyle's walkthrough. The object model is TypeScript Classes and Runtime Identity. The type-level half is TypeScript Infer, Extends, and Ternaries. This note is the compiler.
1. Eight flags
strict turns on a family. It does not turn on everything added after that family shipped.
strictNullChecks,noImplicitAny,strictFunctionTypes,strictBindCallApplystrictPropertyInitialization,noImplicitThis,useUnknownInCatchVariables,alwaysStrict
That set is assumed. New TypeScript made strict the default. This note is what still sits outside it.
Shared packages in this repo (ds, db, auth, intl, content, infra) already add noFallthroughCasesInSwitch, noUncheckedIndexedAccess, noImplicitOverride, and verbatimModuleSyntax. apps/web and apps/api are mostly strict plus paths. Kyle skipped verbatimModuleSyntax. This stack already chose it: type-only imports are import type.
Failure: reading strict: true as "every useful check." The crash that still compiles is usually indexed access.
2. Hygiene
These flags catch typos and dead code. They do not change the type of arr[i].
{
"compilerOptions": {
"paths": { "@/*": ["./src/*"] },
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnusedLabels": false,
"noUncheckedSideEffectImports": true,
"noFallthroughCasesInSwitch": true,
"allowUnreachableCode": false
}
}pathsis DX, not a check. This repo already uses@/*in web and api. Relative../../../is what it replaces.noUnusedLocals/noUnusedParametersunderline a binding that is never read. This repo leaves them off in packages and does not set them on the apps. Unused code is a lint job here, not atscjob.allowUnusedLabels: falseandallowUnreachableCode: falsepromote the default suggestion to an error. Aname:sitting outside an object is a JavaScript label, not a missing property. Areturnthen alogis dead.noUncheckedSideEffectImportserrors onimport "./analytic"when the file isanalytics.ts. A side-effect import with a typo otherwise fails silently.noFallthroughCasesInSwitchis already on in packages. Adjacentcaselabels that share a body are still allowed. A body that runs into the nextcaseis not.
Failure: flipping noUnusedLocals on in app code to "be strict." The unused _ in a handler, the unused catch binding, the unused prop in a stub — lint with an ignore is cheaper than a compiler error on every WIP.
3. Runtime
These three change what a type means, or what emit is allowed. strict does not include them.
const numbers = [1, 2, 3]
numbers[10].toString()
// error with noUncheckedIndexedAccess — type is number | undefined
numbers[10]?.toString()
type Settings = {
darkMode: boolean
[key: string]: string | number | boolean
}
const settings: Settings = { darkMode: true, username: "wds" }
settings.darkMode // ok — declared key
settings.username
// error with noPropertyAccessFromIndexSignature
settings["username"] // ok — index signature, on purposenoUncheckedIndexedAccessmakesarr[i]andrecord[key]intoT | undefined. TypeScript does not track length against the index. The default lies. This is the production crashstrictmisses:Cannot read properties of undefined. Packages in this repo already have it. The apps do not.noPropertyAccessFromIndexSignaturesplits syntax. Dot is a declared key. Brackets are the index signature. A typosettings.darkModstops compiling. This repo leaves it off.erasableSyntaxOnlyforbids syntax that emits a value. Constructor parameter properties (constructor(private name: string)) rewrite the constructor.enumbecomes an object. Types must strip clean — Bun and Node's type stripper do not rewrite. TypeScript Classes prefers#over TypeScriptprivatefor the same reason:#is JavaScript.
Failure: users[0].name after strict, on an array that can be empty. Or an enum in a file that Bun only strips.
4. Preference
Useful. Not free. Two of three are already decided in this repo's packages.
class Box {
close(): void {}
}
class Modal extends Box {
override close(): void {}
}
type User = { name?: string }
const omitted: User = {}
const explicit: User = { name: undefined }
// error with exactOptionalPropertyTypes — optional means missing, not T | undefined
"name" in omitted // false
"name" in explicit // truenoImplicitOverriderequires theoverridekeyword when a child method replaces a parent method. Accidental overwrite is a typo in a class hierarchy. Packages already have it. The object model is TypeScript Classes.noErrorTruncationprints the full type in an error hover. Leave it off. Turn it on to debug one nasty conditional, then turn it off. Hover is not a log.exactOptionalPropertyTypesmakesname?: stringmean the key is absent or astring.{ name: undefined }is not aUser. That is theinoperator bug: a key set toundefinedis still in the object. The most opinionated flag in the video. This repo does not set it.
Failure: override as decoration on a method that does not exist on the parent. The flag is the check that the name still matches.
5. JavaScript
A TS repo that still has .js files needs two switches. They are not type safety for TypeScript.
// analytics.js
export const t = 10allowJslets a.tsfile import that module.apps/webandpackages/dsalready have it.checkJstype-checks every.jsfile. During a migration, that is a flood. Prefer// @ts-checkat the top of one file at a time. TurncheckJson when there is no.jsleft you are willing to ignore.
Failure: checkJs: true on day one of a conversion, then disabling the flag because the log is unreadable. The per-file comment is the migration.
Takeaway
strict is a family. Indexed access, fallthrough, emit, and override sit outside it.
When the question is which flag to add:
- Does
arr[i]lie?noUncheckedIndexedAccess. Add it on the apps. Packages already have it. - Does this syntax emit a value?
enum, constructor parameter properties.erasableSyntaxOnly, or just do not write them.#and string unions. - Is this unused code or a typo? Lint for unused.
noFallthroughCasesInSwitchandnoUncheckedSideEffectImportsfor the rest. Do not promote unused-locals intotscin this repo.