agentleFS
ak-shipak-ship/fullstack-agent-skills/skills/type-tightener/SKILL.md

type-tightener

Skill0 starsChanged 6 months agoMITView on GitHub

Strip `any` (and its cousins `unknown`-without-narrowing, `as` casts, `@ts-ignore`) from a TypeScript codebase. Adds real types where they're missing, enables strict mode safely, and produces a migration plan rather than a Big Bang rewrite. Use when the user says "add types", "remove any", "enable strict mode", "fix the typescript", "tighten the types", or "we have too many `any`s".

The file

Show its rules and text

We split this skill into the rules it gives an agent

14 rules. About 0 are common, 3 are distinctive, and 11 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 11

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Do *not* trigger for: type errors that are real runtime bugs (those need code changes, not type changes), or for "make this build green" …

    line 18
  2. Distinctive:

    Before touching anything, count the debt:

    line 34
  3. Very specific:

    Don't flip strict: true and stare at 4000 errors.

    line 63
  4. Very specific:

    Every // @ts-ignore becomes either:

    line 137
  5. Very specific:

    Add // @ts-expect-error regression tests for any narrowing you introduced:

    line 151
  6. Very specific:

    Use satisfies to keep narrow inferred types while checking conformance to a wider one.

    line 170
  7. Very specific:

    Prefer unknown over any when shape is unknown — the compiler forces narrowing.

    line 171
  8. Very specific:

    Use Awaited<T>, ReturnType<T>, Parameters<T>, NonNullable<T> — they prevent type drift when the source changes.

    line 172
  9. Very specific:

    Don't cast as SomeType over runtime data.

    line 175
  10. Very specific:

    Don't use as unknown as X to bypass a real type mismatch.

    line 176
  11. Very specific:

    Don't type primitives nominally (type UserId = string & { __brand: 'UserId' }) unless your team will actually use the branding.

    line 177
  12. Distinctive:

    Don't type-only-fix. If the type was wrong because the runtime behavior was wrong, the fix is a code change first.

    line 178
  13. Very specific:

    Check tsconfig: strict is on but noUncheckedIndexedAccess is off.

    line 185
  14. Very specific:

    Verify: tsc --noEmit green, 412/412 tests pass, lint clean.

    line 192

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 any from this file"
  • "enable strict mode without breaking everything"
  • "we have 500 ts-ignore comments, help"
  • "type this function properly"
  • "the types are lying — .user is 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:

  1. Reduce the lie surface — fewer anys, fewer unjustified casts, fewer suppressions
  2. Compile — tsc --noEmit green
  3. Don't introduce false confidence — never replace any with a type that's wider than reality (e.g., typing JSON.parse output as a specific interface without runtime validation)
  4. Land in safe slices — one file or one module at a time, never a Big Bang
  5. Document the residue — every remaining any or 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: true on? If not, that's the long-term goal.
  • Specifically, the strict-mode flags that matter most:
    • strictNullChecks — without it, every T is implicitly T | 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-any and @typescript-eslint/no-non-null-assertion set to error or warn?

3 — Migration ladder

Don't flip strict: true and stare at 4000 errors. Climb in this order:

  1. noImplicitAny — turn on first. Fix the resulting errors by adding types or explicit : unknown.
  2. strictNullChecks — biggest behavioral change. Fix one module at a time.
  3. strictFunctionTypes — usually small impact, low pain.
  4. strictPropertyInitialization — class-heavy codebases feel this most.
  5. noUncheckedIndexedAccess — paranoid mode for array/object access. Excellent for new code, painful for old.
  6. 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 --noEmit green
  • Existing tests still pass (you didn't change runtime behavior; tests confirm)
  • Add // @ts-expect-error regression 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 satisfies to keep narrow inferred types while checking conformance to a wider one.
  • Prefer unknown over any when 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 SomeType over runtime data. You're lying to the compiler and to yourself.
  • Don't use as unknown as X to 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 any from src/api/."

  1. Inventory: src/api/ has 34 explicit any, 8 @ts-ignore, 19 as casts.
  2. Check tsconfig: strict is on but noUncheckedIndexedAccess is off; no @typescript-eslint/no-explicit-any rule. Add the lint rule at warn level so new anys get flagged.
  3. 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".
  4. Wave 1: add Zod schemas for the 14 request bodies in schemas/. Replace bodies with await schema.parseAsync(req.body). Inferred types flow through. (14 anys gone)
  5. Wave 2: introduce a typed fetchJson<T>(url, schema): Promise<T> helper. Replace the 10 fetch-wrapper sites. (10 anys gone)
  6. Wave 3: restore generics on the 6 helpers. (6 anys gone)
  7. Residue: 4 anys. Each gets a comment explaining the third-party SDK constraint and a TODO with an issue link.
  8. Suppressions: 6 of 8 @ts-ignore become real fixes; 2 become @ts-expect-error with reasons.
  9. Verify: tsc --noEmit green, 412/412 tests pass, lint clean.
  10. Report: any 34→4, @ts-ignore 8→0, as 19→11.

See also

  • code-auditor — catches the runtime bugs the types might be hiding
  • test-architect — add regression tests where new type-narrowing changed behavior
  • refactor-master — when the typing exercise reveals a structural problem (e.g., a function doing two different things based on input shape)

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.