# Type Tightener

> 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".

- Skill: `ak-ship/type-tightener` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ak-ship/type-tightener`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ak-ship/type-tightener/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ak-ship (https://skillmd.com/u/ak-ship)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ak-ship/type-tightener

---


# 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 `any`s, 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:

```bash
# 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.
```ts
// 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.
```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.
```ts
// 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.
```ts
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.
  ```ts
  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
  // @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 `any`s 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 `any`s gone)
5. Wave 2: introduce a typed `fetchJson<T>(url, schema): Promise<T>` helper. Replace the 10 fetch-wrapper sites. (10 `any`s gone)
6. Wave 3: restore generics on the 6 helpers. (6 `any`s gone)
7. Residue: 4 `any`s. 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)

