TheSkillz

TypeScript Strict Baseline

The tsconfig, ESLint rules and type discipline that make refactors safe

TheSkillz Team TheSkillz Team No reviews yet0 installsv1.0.0
Scan passed · 100/100Human reviewedOfficial · TheSkillz
☆ Star 0

Sets the compiler flags most repos miss (noUncheckedIndexedAccess, exactOptionalPropertyTypes, verbatimModuleSyntax), bans any/as/!/enum with a concrete replacement for each, and models state with branded ids and discriminated unions. Includes a per-directory ratchet so an existing loose codebase tightens without a four-thousand-error big bang.

SKILL.md

TypeScript Strict Baseline

Types you cannot trust are worse than no types, because they buy confidence without paying for it. The point of strictness is not purity; it is that a large refactor becomes a compile-and-fix exercise instead of a hunt through runtime behaviour. Every rule below is here because it turns a class of production bug into a red squiggle.

The flags most repos miss

strict: true is the floor, not the ceiling. These four are the ones that catch real bugs:

Flag What it actually catches
noUncheckedIndexedAccess arr[0] and record[key] typed as present when they are not. The single highest-value flag on this list.
exactOptionalPropertyTypes { a?: string } silently accepting undefined as a written value, so "absent" and "explicitly undefined" stop being the same thing.
verbatimModuleSyntax Type-only imports surviving into emitted JS, which breaks bundlers and side-effect ordering.
isolatedModules Constructs that a per-file transpiler (esbuild, swc, Bun) cannot compile correctly.

Also set noImplicitOverride, noFallthroughCasesInSwitch and forceConsistentCasingInFileNames. Leave skipLibCheck: true — checking dependency types costs minutes and finds nothing you can fix.

Bans, each with its replacement

A ban without a replacement gets ignored. State both.

  • anyunknown plus narrowing. unknown forces the check that any skips. Reserve any for genuinely unrepresentable third-party shapes, with a comment naming the library.
  • as → a type guard. as is an assertion that the compiler stops checking. Prefer function isUser(v: unknown): v is User or a parse at the boundary. as const is a different operator and is fine.
  • ! (non-null) → an early return. if (!user) throw new NotFound() documents the invariant and survives a refactor; user! silently becomes a runtime crash when the invariant changes.
  • enum → const object plus union. enum emits runtime code, does not narrow structurally, and behaves differently under isolatedModules. Use const Status = {...} as const and type Status = typeof Status[keyof typeof Status].
  • Default exports → named exports. Default exports rename freely at the import site, which breaks rename refactors and grep.
  • Barrel files → direct paths. An index.ts re-export pulls the whole directory into the module graph on any import from it, defeating tree-shaking and creating cycles.

Modelling rules

  • Brand your identifiers. type UserId = string & { readonly __brand: "UserId" } means a UserId cannot be passed where an OrgId belongs. Do this the day you have two id types.
  • Discriminated unions over optional-field soup. { status: "loading" } | { status: "ready"; data: T } makes the impossible state unrepresentable; { loading?: boolean; data?: T } invites it.
  • Exhaust every switch. End with default: { const _x: never = value; throw new Error(...) } so adding a variant becomes a compile error at every site that handles it.
  • satisfies over annotation when you want the literal type preserved and checked.
  • readonly on arrays and props by default. Widen only where mutation is the point.

Where validation lives

Parse at the I/O boundary, then trust the type inside. The boundary is: HTTP handlers and Server Actions, queue and webhook consumers, process.env, filesystem and third-party API responses.

Use zod or valibot at those points only. A schema in the middle of the domain is a sign the boundary is in the wrong place. Derive the type from the schema (z.infer) rather than declaring it twice.

Adopting this in an existing codebase

A repo-wide strict: true that produces four thousand errors gets reverted the same day. Ratchet instead:

  1. Turn on the flags in the root tsconfig.json but add an exclude (or a looser tsconfig.legacy.json) covering the directories that fail.
  2. Fix one directory, remove it from the exclusion, commit. Repeat.
  3. Add a CI check that the exclusion list only ever shrinks — a diff that adds a path to it fails.
  4. For lint rules, use ESLint flat-config overrides per directory with the same shrinking rule.

The ratchet is what makes this adoptable. Without it you are asking for a week nobody has.

Verification gate

  • tsc --noEmit passes with the flags actually enabled — check the resolved config with tsc --showConfig, not the file you edited, since extends can override you.
  • grep -rn ": any\|as any\| as unknown as" src/ returns only lines with a justifying comment.
  • No new path was added to the exclusion list.
  • The linter passes, and no rule was downgraded to warn to get there.
  • Paste the command output. Do not describe it from memory.

Reviewing someone else's types

Ask three questions in order: can this value be undefined at runtime and does the type say so; is there a state this type permits that the system cannot actually be in; and if I rename this field, does the compiler find every use. Most type review is those three questions.

Reviews

Sign in to leave a review.

  • Be the first to review this skill.

More in coding