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.
- name
typescript-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.
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. anyis contagious;unknownis honest. Useunknownfor 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.
Recommended tsconfig (2025–2026, TS 5.8+)
{
"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": trueif you import.tspaths. - Libraries:
"declaration": true,"declarationMap": true,"module": "nodenext", and check exports withnpx @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:
| Situation | Approach |
|---|---|
| Monorepo, packages independent | Flip strict per package tsconfig |
| One app, hundreds of errors | Separate 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.
| Need | Use |
|---|---|
| Primitive check | typeof x === "string" |
| Class instance | x instanceof Date |
| Property presence on union | "kind" in x |
| Union with a tag | switch (x.kind) on a discriminant literal |
| Reusable check | Type predicate (x: unknown): x is User (TS 5.5 infers simple ones from filter) |
| Throw if invalid | Assertion function asserts x is User |
| Array element exists | const 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)isunknownunder strict (useUnknownInCatchVariables). Narrow withe instanceof Error ? e.message : String(e).JSON.parsereturnsany. Assign tounknownimmediately.- Typed env: parse
process.envonce 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
unknownwith extra steps. - Constrain:
<K extends keyof T>,<T extends { id: string }>. consttype 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>).satisfieschecks 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
| Hatch | Acceptable when |
|---|---|
as const | Always fine; it narrows |
satisfies | Always fine; it checks |
x as T | At a validated boundary or a brand constructor, with a comment |
x! | Never in new code; replace with an explicit check and a thrown error |
any | Third-party gap, wrapped in a typed function; never exported |
// @ts-expect-error reason | Known 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
noUncheckedIndexedAccessalso affectsfor (let i...)loops; preferfor...of,.entries(), or.at()with a check instead of!.Object.keys(obj)returnsstring[]by design (objects can have extra keys). Use a typed helper only when you control the object’s construction.exactOptionalPropertyTypesbreaks spreading{ a: maybeUndefined }; write...(a !== undefined && { a })or declarea?: string | undefineddeliberately.enumis not erasable and emits runtime code; preferas constobjects plustype X = (typeof X)[keyof typeof X].Array.prototype.filter(Boolean)does not narrow awaynullbefore TS 5.5; write.filter((x): x is T => x != null)on older versions.- Function parameter bivariance: method syntax
m(x: T): voidin interfaces is bivariant; property syntaxm: (x: T) => voidis checked strictly. Prefer the latter. skipLibCheck: truehides errors in your own.d.tsfiles too; run a separate check on published declarations.
Verification
npx tsc --noEmit -p .exits 0 (run for every tsconfig in the repo).npx eslint .with the strict type-checked rules passes.grep -rnE "as any|: any\b|@ts-ignore|!\." src | wc -ldid not increase; report the number.- Type tests run (
npx vitest --typecheckortscover*.test-d.ts). - For libraries:
npx @arethetypeswrong/cli --packshows 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.