type-tightener — types that catch bugs, not types that pretend to
When to use this skill
Trigger when the goal is type safety, not just "make TS shut up". Strong signals:
- "remove
anyfrom this file" - "enable
strictmode without breaking everything" - "we have 500 ts-ignore comments, help"
- "type this function properly"
- "the types are lying —
.useris sometimes undefined"
Do not trigger for: type errors that are real runtime bugs (those need code changes, not type changes), or for "make this build green" requests where the user wants you to suppress, not fix.
The output contract
Type changes that:
- Reduce the lie surface — fewer
anys, fewer unjustified casts, fewer suppressions - Compile —
tsc --noEmitgreen - Don't introduce false confidence — never replace
anywith a type that's wider than reality (e.g., typing JSON.parse output as a specific interface without runtime validation) - Land in safe slices — one file or one module at a time, never a Big Bang
- Document the residue — every remaining
anyor suppression has a comment explaining why
Workflow
1 — Inventory
Before touching anything, count the debt:
# count any (excluding type-fest, third-party d.ts)
rg -t ts ': any\b|<any>|as any' --no-heading -c | head
# count suppressions
rg -t ts '@ts-ignore|@ts-expect-error|@ts-nocheck' --no-heading -c | head
# count casts
rg -t ts ' as [A-Z]' --no-heading -c | head
Show the numbers to the user. Decide together: pick a file to start, or take a sweep approach? Usually start with the highest-leverage module (auth, payments, the public API surface).
2 — Check the config
Look at tsconfig.json:
- Is
strict: trueon? If not, that's the long-term goal. - Specifically, the strict-mode flags that matter most:
strictNullChecks— without it, everyTis implicitlyT | null | undefined. Catches the most bugs.noImplicitAny— without it, missing types fall through silently.strictFunctionTypes— catches variance bugs in callbacks.strictPropertyInitialization— class fields must be initialized.
- Lint config: are
@typescript-eslint/no-explicit-anyand@typescript-eslint/no-non-null-assertionset to error or warn?
3 — Migration ladder
Don't flip strict: true and stare at 4000 errors. Climb in this order:
noImplicitAny— turn on first. Fix the resulting errors by adding types or explicit: unknown.strictNullChecks— biggest behavioral change. Fix one module at a time.strictFunctionTypes— usually small impact, low pain.strictPropertyInitialization— class-heavy codebases feel this most.noUncheckedIndexedAccess— paranoid mode for array/object access. Excellent for new code, painful for old.exactOptionalPropertyTypes— distinguishes{ x?: number }from{ x?: number | undefined }. Skip unless the project relies on the distinction.
For each flag, scope it per directory if needed (tsconfig.strict.json extends base, picks specific include paths) so green areas stay green while you work the brown ones.
4 — Replace any with intent
For each any, ask "what is this actually?" and pick:
Known shape — write the interface or type alias.
// before
function handle(req: any) { return req.body.user }
// after
interface SignupRequest { body: { user: { email: string; name: string } } }
function handle(req: SignupRequest) { return req.body.user }
Unknown shape, parsed from outside (JSON, FormData, query string) — type as unknown, then validate with Zod / Valibot / io-ts.
// before
const data: any = await req.json()
const user = data.user as User
// after
const data: unknown = await req.json()
const result = SignupRequestSchema.safeParse(data)
if (!result.success) return res.status(422).json(result.error)
const user = result.data.user // ← real User type, validated
This is the single biggest win in any TypeScript codebase. Replacing as casts at the boundary with real runtime validation.
Generic that lost its type — re-introduce the generic.
// before
function pick(obj: any, keys: string[]) { ... }
// after
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> { ... }
Truly heterogeneous values (rare) — discriminated union.
type Event =
| { kind: 'click'; target: HTMLElement }
| { kind: 'submit'; form: HTMLFormElement }
| { kind: 'tick'; ms: number }
5 — Replace casts
as X casts lie. Replace with:
- Narrowing — use a type guard.
function isUser(v: unknown): v is User { return typeof v === 'object' && v !== null && 'email' in v && typeof (v as any).email === 'string' } - Schema validation at the boundary (above).
- Real type inference — sometimes the cast is hiding a generic that the compiler could infer if you removed the cast.
The only as that's defensible: narrowing within a discriminated union (as const), or in tests where you're deliberately constructing partial values.
6 — Replace suppressions
Every // @ts-ignore becomes either:
- A real fix (most cases)
// @ts-expect-error <reason>— at least this fails if the underlying issue gets fixed- A documented escape with a TODO and an issue link
// @ts-nocheck at file top → split the file or fix incrementally; never leave it as a permanent solution.
7 — Verify
After the slice:
tsc --noEmitgreen- Existing tests still pass (you didn't change runtime behavior; tests confirm)
- Add
// @ts-expect-errorregression tests for any narrowing you introduced:// @ts-expect-error — must reject when email is missing SignupRequestSchema.parse({ user: { name: 'Ada' } })
8 — Report
Before: any-count 87, @ts-ignore 14, as-casts 32
After: any-count 19, @ts-ignore 0, as-casts 11
Remaining any: 19 (15 in src/legacy/, 4 in third-party glue with TODO comments)
Compile time delta: +0.3s (negligible)
Patterns and anti-patterns
✅ Do:
- Type the boundaries (HTTP, DB, file, env) with schema validation. Trust the inside.
- Use
satisfiesto keep narrow inferred types while checking conformance to a wider one. - Prefer
unknownoveranywhen shape is unknown — the compiler forces narrowing. - Use
Awaited<T>,ReturnType<T>,Parameters<T>,NonNullable<T>— they prevent type drift when the source changes.
❌ Don't:
- Don't cast
as SomeTypeover runtime data. You're lying to the compiler and to yourself. - Don't use
as unknown as Xto bypass a real type mismatch. Find the actual incompatibility. - Don't type primitives nominally (
type UserId = string & { __brand: 'UserId' }) unless your team will actually use the branding. Otherwise it's friction with no payoff. - Don't type-only-fix. If the type was wrong because the runtime behavior was wrong, the fix is a code change first.
Example invocation
User: "remove every
anyfromsrc/api/."
- Inventory:
src/api/has 34 explicitany, 8@ts-ignore, 19ascasts. - Check tsconfig:
strictis on butnoUncheckedIndexedAccessis off; no@typescript-eslint/no-explicit-anyrule. Add the lint rule at warn level so newanys get flagged. - Triage the 34: 14 are unparsed request bodies, 10 are response types from a fetch wrapper, 6 are utility helpers that lost their generic, 4 are genuine "I don't know".
- Wave 1: add Zod schemas for the 14 request bodies in
schemas/. Replace bodies withawait schema.parseAsync(req.body). Inferred types flow through. (14anys gone) - Wave 2: introduce a typed
fetchJson<T>(url, schema): Promise<T>helper. Replace the 10 fetch-wrapper sites. (10anys gone) - Wave 3: restore generics on the 6 helpers. (6
anys gone) - Residue: 4
anys. Each gets a comment explaining the third-party SDK constraint and a TODO with an issue link. - Suppressions: 6 of 8
@ts-ignorebecome real fixes; 2 become@ts-expect-errorwith reasons. - Verify:
tsc --noEmitgreen, 412/412 tests pass, lint clean. - Report:
any34→4,@ts-ignore8→0,as19→11.
See also
code-auditor— catches the runtime bugs the types might be hidingtest-architect— add regression tests where new type-narrowing changed behaviorrefactor-master— when the typing exercise reveals a structural problem (e.g., a function doing two different things based on input shape)