Skip to content
Back

TypeScript Beyond Strict

TypeScript

strict is eight flags. The bugs that still type-check live in the rest of tsconfig

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, strictBindCallApply
  • strictPropertyInitialization, 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].


jsonc
{
  "compilerOptions": {
    "paths": { "@/*": ["./src/*"] },
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "allowUnusedLabels": false,
    "noUncheckedSideEffectImports": true,
    "noFallthroughCasesInSwitch": true,
    "allowUnreachableCode": false
  }
}

  • paths is DX, not a check. This repo already uses @/* in web and api. Relative ../../../ is what it replaces.
  • noUnusedLocals / noUnusedParameters underline 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 a tsc job.
  • allowUnusedLabels: false and allowUnreachableCode: false promote the default suggestion to an error. A name: sitting outside an object is a JavaScript label, not a missing property. A return then a log is dead.
  • noUncheckedSideEffectImports errors on import "./analytic" when the file is analytics.ts. A side-effect import with a typo otherwise fails silently.
  • noFallthroughCasesInSwitch is already on in packages. Adjacent case labels that share a body are still allowed. A body that runs into the next case is 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.


ts
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 purpose

  • noUncheckedIndexedAccess makes arr[i] and record[key] into T | undefined. TypeScript does not track length against the index. The default lies. This is the production crash strict misses: Cannot read properties of undefined. Packages in this repo already have it. The apps do not.
  • noPropertyAccessFromIndexSignature splits syntax. Dot is a declared key. Brackets are the index signature. A typo settings.darkMod stops compiling. This repo leaves it off.
  • erasableSyntaxOnly forbids syntax that emits a value. Constructor parameter properties (constructor(private name: string)) rewrite the constructor. enum becomes an object. Types must strip clean — Bun and Node's type stripper do not rewrite. TypeScript Classes prefers # over TypeScript private for 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.


ts
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 // true

  • noImplicitOverride requires the override keyword 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.
  • noErrorTruncation prints 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.
  • exactOptionalPropertyTypes makes name?: string mean the key is absent or a string. { name: undefined } is not a User. That is the in operator bug: a key set to undefined is 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.


ts
// analytics.js
export const t = 10

  • allowJs lets a .ts file import that module. apps/web and packages/ds already have it.
  • checkJs type-checks every .js file. During a migration, that is a flood. Prefer // @ts-check at the top of one file at a time. Turn checkJs on when there is no .js left 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:

  1. Does arr[i] lie? noUncheckedIndexedAccess. Add it on the apps. Packages already have it.
  2. Does this syntax emit a value? enum, constructor parameter properties. erasableSyntaxOnly, or just do not write them. # and string unions.
  3. Is this unused code or a typo? Lint for unused. noFallthroughCasesInSwitch and noUncheckedSideEffectImports for the rest. Do not promote unused-locals into tsc in this repo.

Recap Q&A