Audit TypeScript types for weak invariants — unnecessary casts, loose optionality, defensive `?.`/`??` masking missing guarantees, leaky discriminated unions, and runtime checks the type system should enforce. Use when: tightening domain models, reducing type assertions, increasing type coverage, reviewing discriminated unions, or establishing a type-safety baseline before refactoring.
Install
npx skillscat add skyosev/agent-skills/invariant-hunter-ts Install via the SkillsCat registry.
Invariant Hunter
Audit TypeScript code to make the type system enforce invariants that are currently left to runtime, convention, or as
assertions. The goal: illegal states become unrepresentable, and consumers narrow via control flow without casts.
When to Use
- Tightening a domain model after initial prototyping
- Reducing
asassertions and type casts across a codebase - Migrating from optional/defaulted fields to required fields with API-boundary defaults
- Reviewing discriminated unions for completeness, drift, or consumer ergonomics
- Before a major refactor to establish a type-safety baseline
Core Principles
Types are documentation that compiles. Encode invariants in the type system. If it cannot be encoded without
excessive complexity, validate at runtime with a clear error.Resolve at construction boundaries. Defaults and validation belong where data is created or enters the system —
public API entry points, builders, factories. Downstream functions should require their inputs. If a caller "always
passes X", make X required and push the default to the construction boundary.Every
?is a branch. A?:field meansT | undefined— the consumer must handle the absent case. Only use
optional when the domain genuinely permits absence, not as a convenience for callers.?.and??are symptoms. Optional chaining and nullish coalescing have legitimate uses (truly optional data,
external API responses), but each occurrence is a branch the reader must reason about. In non-boundary code, they
signal a type that is too loose. The fix is tightening the upstream type, not adding defensive access.Discriminants: single source of truth. The discriminant field must be exhaustive. Redundant fields carrying the
same information (e.g.,shapevsarch.kind) are a drift risk — eliminate one or derive it.asmust be justified. Type assertions should be minimized, but runtime-guarded casts are acceptable when
the cast immediately follows a runtime check and TypeScript's control flow analysis cannot correlate the
discriminants. Do not blindly refactor into verbose alternatives that harm ergonomics without improving safety.Fail fast. When an invariant is violated, throw immediately — do not silently return a default or catch-and-log.
Invariant violations are programmer errors; they should crash loudly to surface bugs. Empty catches and broadcatch(e) { log(e) }blocks that only log without recovery or re-throw are never acceptable in non-boundary code.
(Try/catch at defined error boundaries — top-level handlers, middleware — with actual recovery logic is fine; see
Canonical Exceptions.)Eliminate type-system bypasses.
as any,as unknown as T,@ts-ignore,@ts-expect-errorare escape hatches.
Each must be justified (why necessary), scoped (boundary layers only), and temporary (tracked as tech debt).
Canonical Exceptions
Not every finding requires action. Document these but do not flag as "must-fix":
| Pattern | When Acceptable |
|---|---|
Runtime-guarded as Extract<...> |
Cast immediately follows a runtime check |
| Optional utility parameters | Helper accepting optional when domain type requires |
?? / ?. at true boundaries |
External API responses, user input, config defaults |
| Try/catch at error boundaries | Top-level handlers, middleware with defined recovery |
| Type bypasses in boundary layers | JSON parsing, FFI, library workarounds — with comment |
What to Hunt
1. Unnecessary Casts
as assertions and ! non-null assertions where narrowing should work or the type can be tightened.
Signals:
asnot preceded by a runtime check validating the assertion!on a value that could be made non-optional at its sourceas unknown as Tdouble-cast bypasses
Action: Tighten the upstream type or add a runtime guard. If runtime-guarded, document as acceptable.
2. Loose Optionality
?: fields that are always present after construction, or mutually exclusive optional fields that should be a
discriminated union.
Signals:
- Optional field with
??default in every consumer - Two optional fields never both present / both absent
- Field that becomes required after a pipeline stage but carries
?:throughout
Action: Make required at construction boundary. Replace mutually exclusive optionals with a discriminated union.
3. Defensive Access in Non-Boundary Code
?. and ?? that compensate for a loose upstream type rather than handling genuine absence.
Signals:
?.on a property that is always present given the current context??applying a default that was already resolved at the entry point- Shared helper using
??to handle cases that should be separate functions
Action: Tighten the upstream type so the value is guaranteed present. Move defaults to construction boundaries.
4. Leaky Discriminated Unions
Unions with redundant discriminants, missing exhaustiveness checks, or fields that leak across variants.
Signals:
- Parallel fields carrying equivalent information (e.g.,
typevs a boolean flag) switchover discriminant withoutdefault: assertNever(x)- Fields existing on all variants but meaningful only on some (missing
?: neverguards) - Call sites that bypass narrowing with a cast instead of
if/switch
Action: Eliminate redundant discriminants. Add exhaustiveness guards. Apply ?: never to variant-exclusive fields.
5. Runtime Checks Promotable to Types
Guards, assertions, and validations that could be compile-time guarantees.
Signals:
if (node.config)whereconfigshould be guaranteed by the discriminant- Branded type candidates: IDs, units, validated strings used as plain
string - Empty-check branches that a
NonEmptyArray<T>type would eliminate - Mutation guards that
Readonly<T>would enforce - Object literals without
satisfieswhere shape conformance is intended but unchecked - Validation functions that return
booleaninstead of usingasserts param is Tto narrow the caller's scope - Mutable arrays/objects used as config where
as constwould enforce literal types and immutability
Action: Promote to type constraint. Use satisfies to validate shape at assignment without widening. Use assertion
functions (asserts x is T) for runtime guards that should narrow control flow. Use as const for fixed configuration
and lookup objects. If type complexity would be excessive, keep as runtime with documentation.
6. Type-System Bypasses and Error Suppression
as any, @ts-ignore, double-casts, empty catch blocks, and catch-only-log patterns.
Signals:
as anyoras unknown as Twithout justification comment@ts-ignore/@ts-expect-errorwithout explanationcatch { }orcatch(e) { console.log(e) }with no recovery logic- Silent fallback (
return [],return null) on invalid input instead of throwing
Action: Fix the underlying type issue. If bypass is necessary, add justification and track as tech debt. For catch
blocks: remove if suppressing invariant violations, keep if at a defined error boundary with recovery.
Audit Workflow
Phase 1: Establish Baseline
Resolve audit surface. The prompt may specify the scope as:
- Diff: files changed on the current branch vs base (
main/master) - Path: specific files, folders, or layers
- Codebase: the entire project
If unspecified, default to codebase. For diff mode, resolve the file list:
BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo main) SCOPE=$(git diff --name-only $(git merge-base HEAD $BASE)...HEAD)Constrain all subsequent scans to the resolved surface.
- Diff: files changed on the current branch vs base (
Record
tsconfig.jsonstrictness flags (strict,strictNullChecks,exactOptionalPropertyTypes,noUncheckedIndexedAccess). If key flags are off, note this prominently.Scan for patterns:
EXCLUDE='--glob !**/*.test.* --glob !**/*.spec.* --glob !**/node_modules/**' rg --pcre2 '\bas\s+(?!const\b)' --type ts $EXCLUDE # as assertions rg --pcre2 '[a-zA-Z0-9_\]\)]\!(?!=)' --type ts $EXCLUDE # non-null assertions rg ':\s*any\b|<any>|\bas\s+any\b' --type ts $EXCLUDE # any usage rg '@ts-ignore|@ts-expect-error' --type ts # suppressions rg '\?\.' --type ts $EXCLUDE # optional chaining rg '\?\?' --type ts $EXCLUDE # nullish coalescing rg 'as\s+(unknown|any)\s+as' --type ts $EXCLUDE # double-casts rg -U 'catch\s*\([^)]*\)\s*\{\s*(//.*)?\s*\}' --type ts $EXCLUDE # empty catches rg 'satisfies\s' --type ts $EXCLUDE # satisfies usage (adoption check) rg 'asserts\s+\w+\s+is\s' --type ts $EXCLUDE # assertion functionsProduce counts by category, grouped by module/layer.
Phase 2: Evaluate Discriminated Unions
For each discriminated union:
- Inference: Can consumers narrow via
if/switchwithoutas? List bypass sites. - Redundancy: Is the discriminant the single source of truth? Flag parallel fields.
- Exhaustiveness: Do
switchstatements haveassertNeverdefaults? - Never guards: Do exclusive-to-variant fields use
?: neveron other variants?
Phase 3: Evaluate Optionality and Defensive Access
For each ?: field in core types: Is absence meaningful, or always present after construction?
For each ?. / ?? in non-boundary code: Is the property guaranteed present in this context?
Classify each as: tighten type / move default to boundary / acceptable (see Canonical Exceptions).
Phase 4: Evaluate Runtime vs Type Enforcement
For each runtime guard/assertion, classify:
- Promote to type: replace runtime check with compile-time guarantee
- Keep as runtime: external boundary, serialization, or excessive type complexity
- Remove: redundant with existing type guarantees
Phase 5: Evaluate Error Handling and Bypasses
For each catch block: classify as Remove (no recovery) / Keep (defined boundary) / Move (too broad).
For each type bypass: verify justification, scoping, and tech debt tracking.
Phase 6: Evaluate Ergonomics
- Count branches required to handle each variant in consuming code.
- Identify boilerplate patterns (repeatedly checking discriminant then casting).
- Test extensibility: can a new variant be added by extending the union and handling new cases?
Output Format
Save as YYYY-MM-DD-invariant-hunter-audit-{$LLM-name}.md in the project's docs folder (or project root if no docs folder exists).
# Invariant Hunter Audit — {date}
## Scope
- Surface: {diff / path / codebase}
- Files: {count or list}
- Exclusions: {list}
## Compiler Context
- tsconfig: {path}
- `strict`: {on/off}, `strictNullChecks`: {on/off}
- `exactOptionalPropertyTypes`: {on/off}, `noUncheckedIndexedAccess`: {on/off}
## Baseline
| Category | Count |
| -------- | ----- |
| `as` assertions (non-const) | {n} |
| Non-null assertions `!` | {n} |
| `any` usage | {n} |
| `@ts-ignore` / `@ts-expect-error` | {n} |
| Double-cast bypasses | {n} |
| Empty/logging-only catch blocks | {n} |
| Optional fields in core types | {n} |
| `??` in non-boundary code | {n} |
| `?.` in non-boundary code | {n} |
## Discriminated Unions
### {UnionName}
- Discriminant: `{field}`
- Inference: {pass/fail}
- Redundancy: {none / {field} duplicates {other}}
- Exhaustiveness: {pass/fail}
- Never guards: {pass/fail}
## Optionality and Defensive Access
| # | Field/Expression | Location | Current | Proposed | Rationale |
| - | ---------------- | -------- | ------- | -------- | --------- |
| 1 | ... | file:line | optional | required | ... |
## Runtime → Type Promotions
| # | Invariant | Location | Current | Proposed | Complexity |
| - | --------- | -------- | ------- | -------- | ---------- |
| 1 | ... | file:line | runtime guard | type constraint | low/med/high |
## Error Handling and Bypasses
| # | Location | Pattern | Classification | Action |
| - | -------- | ------- | -------------- | ------ |
| 1 | file:line | `as any` | Remove | Fix type |
## Ergonomics
- Consumer branch count per variant: ...
- Boilerplate patterns: ...
- Extensibility: ...
## Recommendations (Priority Order)
1. **Must-fix**: {narrowing failures, forced casts, silent fallbacks masking bugs}
2. **Should-fix**: {defaults in wrong layer, always-present optionals, catch cleanup}
3. **Consider**: {ergonomic improvements, extensibility prep}Operating Constraints
- No code edits. This skill produces an audit report only. Implementation is a separate step.
- Scope: type invariants only. Do not flag type design/architecture (→ type-hunter-ts), module boundary issues
(→ boundary-hunter-ts), structural complexity (→ simplicity-hunter-ts), class/interface design (→ solid-hunter-ts), missing
documentation (→ doc-hunter-ts), security (→ security-hunter-ts), test quality (→ test-hunter-ts), or cosmetic style
(→ slop-hunter-ts). If a finding doesn't answer "is this type tight enough?", it doesn't belong here. - Evidence required. Every finding must cite
file/path.ext:linewith the exact code. - Architecture-first. Understand the project's intended layering before flagging violations. Ask if unclear.
- Complexity honesty. If encoding an invariant requires conditional types three levels deep, say so and recommend
runtime validation. - Challenge assumptions. If the current type design makes a deliberate trade-off, acknowledge it rather than
mechanically flagging it. - Prioritize: dead fallbacks > representational correctness > discriminated unions > optional strictness flags.
Assess cascading effects — removing fallbacks may triggernoUnusedParameters; include cleanup.