Languages No. 122

TypeScript Strict skill for Claude Code

Make impossible states fail to compile. Claude migrates and writes TypeScript with the strict family plus noUncheckedIndexedAccess and exactOptionalPropertyTypes, one flag at a time with an error count that only goes down. It narrows instead of casting, validates external data from unknown, models state as tagged unions with never checks, and backs public types with compile-time tests.

  • nametypescript-strict
  • size253 lines · ≈2,500 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes and migrates TypeScript under full strict mode — tsconfig flags beyond strict, narrowing, discriminated unions, generics with constraints, satisfies, unknown at boundaries, and type-level tests with expectTypeOf or @ts-expect-error. Use when enabling strict or noUncheckedIndexedAccess, removing any, fixing type errors, designing typed APIs, or when tsc output is full of implicit any and possibly undefined errors.

Try asking

  • Turn on strict mode in this repo and fix the errors without sprinkling any
  • Refactor this loading/error/data state into a discriminated union
  • Why does tsc say this array access is possibly undefined, and what is the right fix?

Use it when

  • Enabling strict, noUncheckedIndexedAccess, or exactOptionalPropertyTypes
  • A codebase is littered with any, casts, or non-null assertions
  • Designing a typed public API or generic helper
  • Parsing JSON, env vars, or request bodies into typed values

Skip it when

  • Validating form input UX in React specifically — use forms-validation
  • The question is about React component patterns rather than types — use react-patterns

How to check the result

tsc --noEmit passes for every tsconfig, the strict typescript-eslint config is clean, and the grep count of any, ts-ignore and non-null assertions did not rise.

The common mistake it prevents

Silencing errors with as casts or the ! operator during a strict migration, which keeps the old bugs while hiding exactly the spots the compiler found.

~/.claude/skills/typescript-strict/SKILL.md

--- name: typescript-strict description: Writes and migrates TypeScript under full strict mode — tsconfig flags beyond strict, narrowing, discriminated unions, generics with… license: MIT ---

Done well means: tsc --noEmit passes with strict flags on, there is no any that a reader cannot justify in one sentence, data from the outside world is unknown until validated, and impossible states are unrepresentable rather than guarded at runtime.

Ground rules

  • Fix the type, not the error message. A cast (as X) or ! that silences tsc is a claim you must be able to prove; if you cannot, narrow instead.
  • any is contagious; unknown is honest. Use unknown for anything untyped and narrow it.
  • Never weaken tsconfig to make a change compile. Tighten flags in their own commits.
  • Prefer inference for locals and return types of private helpers; annotate public function signatures and exported constants.
  • Types that the runtime cannot check (JSON, env, request bodies, localStorage) need a runtime validator (zod, valibot, arktype) whose inferred type is the source of truth.
{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2023", "dom", "dom.iterable"],
    "module": "preserve",              // bundler apps; use "nodenext" for Node libraries
    "moduleResolution": "bundler",     // implied by "preserve"; "nodenext" for Node
    "strict": true,
    "noUncheckedIndexedAccess": true,  // arr[i] and record[key] are T | undefined
    "exactOptionalPropertyTypes": true,// {a?: string} rejects {a: undefined}
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "verbatimModuleSyntax": true,      // forces `import type`, predictable emit
    "isolatedModules": true,
    "skipLibCheck": true,
    "noEmit": true
  }
}
  • Running TS directly in Node (22.18+/23.6+ strip types natively): add "erasableSyntaxOnly": true (TS 5.8) so enums, namespaces, and parameter properties are rejected, and "rewriteRelativeImportExtensions": true if you import .ts paths.
  • Libraries: "declaration": true, "declarationMap": true, "module": "nodenext", and check exports with npx @arethetypeswrong/cli --pack.

Migrating an existing codebase to strict

- [ ] 1. Baseline: run `npx tsc --noEmit -p . | grep -c "error TS"` and save the count
- [ ] 2. Turn on one flag at a time, in this order:
         noImplicitAny -> strictNullChecks -> remaining strict family ->
         noUncheckedIndexedAccess -> exactOptionalPropertyTypes
- [ ] 3. Fix errors leaf-first (utils, models) so fixes propagate upward
- [ ] 4. Where a file is too large to fix now, isolate it, do not loosen the flag
- [ ] 5. Gate CI on the count never increasing until it reaches zero
- [ ] 6. Ban new escape hatches with lint rules (below)

Counting errors per file to pick targets:

npx tsc --noEmit -p . 2>&1 | grep -oE '^[^(]+\(' | sort | uniq -c | sort -rn | head -20

Incremental options when a big-bang flip is impossible:

SituationApproach
Monorepo, packages independentFlip strict per package tsconfig
One app, hundreds of errorsSeparate tsconfig.strict.json with include listing migrated files; run both in CI
Few hot files block the flag// @ts-expect-error TODO(strict): reason per line, tracked by grep count

Prefer @ts-expect-error over @ts-ignore: it fails once the error disappears, so stale suppressions get cleaned up.

Narrowing toolkit

Pick the narrowing tool that the compiler understands natively before writing a guard.

NeedUse
Primitive checktypeof x === "string"
Class instancex instanceof Date
Property presence on union"kind" in x
Union with a tagswitch (x.kind) on a discriminant literal
Reusable checkType predicate (x: unknown): x is User (TS 5.5 infers simple ones from filter)
Throw if invalidAssertion function asserts x is User
Array element existsconst first = arr[0]; if (first === undefined) return;

Discriminated unions with exhaustiveness:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "rect":   return s.width * s.height;
    default: {
      const _exhaustive: never = s;   // compile error when a new kind is added
      throw new Error(`unhandled shape: ${JSON.stringify(_exhaustive)}`);
    }
  }
}

Model states, not flags: replace { loading: boolean; data?: T; error?: string } with { status: "idle" } | { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: string }.

Boundaries: unknown in, validated types out

import { z } from "zod";

const User = z.object({ id: z.string().uuid(), email: z.string().email(), age: z.number().int().optional() });
type User = z.infer<typeof User>;

export async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`fetchUser ${id}: HTTP ${res.status}`);
  const json: unknown = await res.json();   // res.json() returns any; pin it to unknown
  return User.parse(json);
}
  • catch (e) is unknown under strict (useUnknownInCatchVariables). Narrow with e instanceof Error ? e.message : String(e).
  • JSON.parse returns any. Assign to unknown immediately.
  • Typed env: parse process.env once at startup with a schema; export the result.

Generics that pull their weight

  • Add a type parameter only if it relates two or more positions (input to output, argument to argument). A generic used once is just unknown with extra steps.
  • Constrain: <K extends keyof T>, <T extends { id: string }>.
  • const type parameters (TS 5.0) keep literal types: function tuple<const T extends readonly unknown[]>(...xs: T): T.
  • NoInfer<T> (TS 5.4) stops a parameter from widening inference: function pick<T extends string>(opts: T[], fallback: NoInfer<T>).
  • satisfies checks shape without widening:
const routes = {
  home: "/",
  user: "/users/:id",
} satisfies Record<string, `/${string}`>;
// routes.user is still the literal "/users/:id"
  • Branded types for IDs that must not mix:
type UserId = string & { readonly __brand: "UserId" };
const asUserId = (s: string): UserId => s as UserId; // the only allowed cast, at the parse boundary

Escape hatches: when each is acceptable

HatchAcceptable when
as constAlways fine; it narrows
satisfiesAlways fine; it checks
x as TAt a validated boundary or a brand constructor, with a comment
x!Never in new code; replace with an explicit check and a thrown error
anyThird-party gap, wrapped in a typed function; never exported
// @ts-expect-error reasonKnown compiler/library bug, linked issue

Enforce with typescript-eslint (strictTypeChecked config):

// eslint.config.js
import tseslint from "typescript-eslint";
export default tseslint.config(
  ...tseslint.configs.strictTypeChecked,
  { languageOptions: { parserOptions: { projectService: true } } },
  { rules: {
      "@typescript-eslint/no-explicit-any": "error",
      "@typescript-eslint/no-non-null-assertion": "error",
      "@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": "allow-with-description" }],
      "@typescript-eslint/switch-exhaustiveness-check": "error",
  } },
);

Type-level tests

Public types and helpers deserve tests that fail at compile time.

// vitest: files run through `vitest --typecheck` or `*.test-d.ts`
import { expectTypeOf, test } from "vitest";
import { area, type Shape } from "./shape";

test("area accepts shapes and returns number", () => {
  expectTypeOf(area).parameter(0).toEqualTypeOf<Shape>();
  expectTypeOf(area).returns.toBeNumber();
});

// @ts-expect-error -- triangle is not a Shape kind
area({ kind: "triangle", base: 1 });

Without vitest: the expect-type package gives the same expectTypeOf API under plain tsc; tsd suits published libraries.

Gotchas

  • noUncheckedIndexedAccess also affects for (let i...) loops; prefer for...of, .entries(), or .at() with a check instead of !.
  • Object.keys(obj) returns string[] by design (objects can have extra keys). Use a typed helper only when you control the object’s construction.
  • exactOptionalPropertyTypes breaks spreading { a: maybeUndefined }; write ...(a !== undefined && { a }) or declare a?: string | undefined deliberately.
  • enum is not erasable and emits runtime code; prefer as const objects plus type X = (typeof X)[keyof typeof X].
  • Array.prototype.filter(Boolean) does not narrow away null before TS 5.5; write .filter((x): x is T => x != null) on older versions.
  • Function parameter bivariance: method syntax m(x: T): void in interfaces is bivariant; property syntax m: (x: T) => void is checked strictly. Prefer the latter.
  • skipLibCheck: true hides errors in your own .d.ts files too; run a separate check on published declarations.

Verification

  1. npx tsc --noEmit -p . exits 0 (run for every tsconfig in the repo).
  2. npx eslint . with the strict type-checked rules passes.
  3. grep -rnE "as any|: any\b|@ts-ignore|!\." src | wc -l did not increase; report the number.
  4. Type tests run (npx vitest --typecheck or tsc over *.test-d.ts).
  5. For libraries: npx @arethetypeswrong/cli --pack shows no resolution problems.

Output when migrating or reviewing

**Flags enabled:** strictNullChecks, noUncheckedIndexedAccess
**Errors:** 412 -> 0 (37 files touched)
**Escape hatches added:** 3 `@ts-expect-error` (linked to issue #123), 0 `any`
**Behavior changes found:** 2 real bugs surfaced (null user in billing.ts:88, empty array in report.ts:41)
**Next flag:** exactOptionalPropertyTypes (est. 60 errors)

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc