Use when designing discriminated unions, debugging control-flow analysis, choosing satisfies vs as, building branded types, writing user-defined type guards, or composing conditional types and template literal types. Triggers: "Type X is not assignable to type Y" after a type guard, exhaustiveness checks via never, satisfies vs explicit annotation, infer in conditional types, mapped types with key remapping, distributive conditional types, type-narrowing inside callbacks losing the narrowed type. NOT for runtime validation only (zod/yup), tsc compiler internals, or design-time-only types not used at runtime.
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Um comando direto ignora o prompt de revisão. Verifique a origem antes de executá-lo.
Use when designing discriminated unions, debugging control-flow analysis, choosing satisfies vs as, building branded types, writing user-defined type guards, or composing conditional types and template literal types. Triggers: "Type X is not assignable to type Y" after a type guard, exhaustiveness checks via never, satisfies vs explicit annotation, infer in conditional types, mapped types with key remapping, distributive conditional types, type-narrowing inside callbacks losing the narrowed type. NOT for runtime validation only (zod/yup), tsc compiler internals, or design-time-only types not used at runtime.
allowed-tools
Read,Grep,Glob,Edit,Write,Bash
metadata
{"category":"Frontend & UI","tags":["typescript","type-system","generics","narrowing","discriminated-unions","branded-types"],"provenance":{"kind":"first-party","owners":["port-daddy"]},"pairs-with":[{"skill":"error-handling-patterns","reason":"Result/Either error channels are discriminated unions; that skill picks the error strategy, this one makes the narrowing and exhaustiveness airtight."},{"skill":"output-contract-enforcer","reason":"Runtime JSON-schema validation at trust boundaries is the runtime companion to the compile-time narrowing this skill designs."}],"io-contract":{"kind":"deliverable","consumes":["[Truncated]","[Truncated]"],"produces":["[Truncated]","[Truncated]"]}}
TypeScript Narrowing Expert
The interesting part of TypeScript isn't generics — it's narrowing. Control-flow analysis turns wide types into specific ones inside conditional branches. Most "TypeScript is fighting me" stories come from narrowing not propagating where the engineer expected.
Decision diagram
flowchart TD
A[Type error after a check] --> B{Is the check on a discriminant property?}
B -->|No, structural check| F1[FIX: add a literal discriminant `kind: 'a' | 'b'`]
B -->|Yes| C{Narrowing lost inside a callback?}
C -->|Yes| F2[FIX: hoist the narrowed value into a const before the callback]
C -->|No| D{Is `as` being used?}
D -->|Yes, to silence error| F3[FIX: replace with type guard or `satisfies`]
D -->|No, satisfies| E{Need exhaustiveness?}
E -->|Yes| F4[FIX: switch + `_exhaustive: never` default]
E -->|No| G{ID confused with other ID type?}
G -->|Yes| F5[FIX: branded type via intersection w/ unique symbol]
G -->|No| H[Done]
When to use
Modeling state machines or sum types in TS.
A type guard works at the call site but the narrowed type is lost inside a callback.
Choosing between as, satisfies, and an explicit annotation.
Branded types for ID disambiguation (UserID vs OrderID).
Conditional types that need to extract a piece of a generic.
Core capabilities
Discriminated unions — the workhorse
typeResult<T> =
| { kind: 'ok'; value: T }
| { kind: 'err'; error: Error };
function unwrap<T>(r: Result<T>): T {
if (r.kind === 'err') throw r.error;
return r.value; // narrowed to { kind: 'ok'; value: T }
}
The discriminant (kind) must be a literal in every variant. String, number, or boolean literals all work; symbol literals don't narrow.
Exhaustiveness checks:
functiondescribe(r: Result<string>): string {
switch (r.kind) {
case'ok': return`ok: ${r.value}`;
case'err': return`err: ${r.error.message}`;
default: {
const_exhaustive: never = r; // compile error if a case is missingreturn _exhaustive;
}
}
}
satisfies vs annotation vs as
Three different operators, often confused:
typeColor = { hex: string; name: string };
// Annotation: forces the value to type Color, widens it.consta: Color = { hex: '#fff', name: 'white' };
a.hex; // typed as string// satisfies: validates the value matches Color, keeps the narrow inferred type.const b = { hex: '#fff', name: 'white' } asconstsatisfiesColor;
b.hex; // typed as '#fff' (literal)// as: a coercion. Bypasses checks; lies to the compiler.const c = { hex: '#fff' } asColor; // works, but missing `name`
c.name; // typed as string, but undefined at runtime
Default to satisfies when you want validation without losing inference. as is a last resort.
User-defined type guards
functionisError(x: unknown): x is Error {
return x instanceofError;
}
try { /* ... */ } catch (e) {
if (isError(e)) console.error(e.message); // narrowed to Error
}
For shape checks:
functionhasMessage(x: unknown): x is { message: string } {
returntypeof x === 'object' && x !== null && 'message'in x && typeof (x as { message: unknown }).message === 'string';
}
Branded types for ID safety
typeBrand<T, B> = T & { readonly__brand: B };
typeUserID = Brand<string, 'UserID'>;
typeOrderID = Brand<string, 'OrderID'>;
functionUserID(s: string): UserID { return s asUserID; }
functionOrderID(s: string): OrderID { return s asOrderID; }
declarefunctiongetUser(id: UserID): Promise<User>;
const u = UserID('abc');
const o = OrderID('xyz');
getUser(u); // okgetUser(o); // type error — OrderID is not UserID
The as is contained in the factory; outside it, the brand is enforced.
Narrowing inside callbacks
functionprocess(value: string | null) {
if (value === null) return;
// `value` is `string` here.someAsync().then(() => {
// `value` is `string | null` again — narrowing doesn't survive across closure boundaries.
});
}
Two fixes:
// 1. Re-narrow inside.someAsync().then(() => {
if (value === null) return;
// narrowed
});
// 2. Capture into a const, which IS narrowed.const v = value; // typed `string`someAsync().then(() => {
v.length; // ok
});
in operator narrowing
typeCat = { meow: () =>void };
typeDog = { bark: () =>void };
functionspeak(animal: Cat | Dog) {
if ('meow'in animal) animal.meow(); // narrowed to Catelse animal.bark(); // narrowed to Dog
}
Conditional types with infer
typeAwaitedReturn<F> = F extends (...args: any[]) => Promise<infer R> ? R : never;
typeElement<A> = A extendsArray<infer E> ? E : never;
type X = AwaitedReturn<() =>Promise<{ id: string }>>; // { id: string }type Y = Element<number[]>; // number
infer introduces a type variable that captures the matched portion. Useful for unwrapping promises, arrays, function returns.
Distributive conditional types
typeToArray<T> = T extendsany ? T[] : never;
type X = ToArray<string | number>; // string[] | number[]
The conditional distributes over union members. Wrap to disable:
typeToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type Y = ToArrayNonDist<string | number>; // (string | number)[]
Mapped types with key remapping
typeGetters<T> = {
[K in keyof T as`get${Capitalize<string & K>}`]: () => T[K];
};
type X = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }
Template literal types (Capitalize, Uppercase, ${...}) compose with mapped types for ergonomic API generation.
Anti-patterns
Using as to silence errors
Symptom: Code compiles; production throws "Cannot read property of undefined".
Diagnosis:as told the compiler to trust you about a shape that wasn't real.
Fix: Add a runtime check (zod, manual) that produces a typed value, or fix the shape so as isn't needed.
Discriminant that isn't a literal
Symptom:switch (kind) cases don't narrow.
Diagnosis:kind: string instead of kind: 'ok' | 'err'.
Fix: Use literal types for discriminants. Mark with as const if pulled from data.
Type guard that returns boolean instead of x is Foo
Symptom: Function returns true; caller doesn't see narrowing.
Diagnosis: Return type is boolean, not a type predicate.
Fix: Declare the return as function isFoo(x: unknown): x is Foo.
Branded type without a factory
Symptom: Engineers cast strings to UserID everywhere; brand provides no safety.
Diagnosis: No central place that knows the rules for valid IDs.
Fix: A factory that validates (regex, length) and casts. Outside the factory, brand is enforced.
Closure-captured variable losing narrowing
Symptom: Code inside .then says value could be null, but you checked above.
Diagnosis: Narrowing applies to the variable at one point in time; closures see the union again.
Fix: Capture into a const after narrowing, or re-narrow inside the closure.
Infinite recursion in conditional types
Symptom:Type instantiation is excessively deep and possibly infinite.
Diagnosis: Recursion depth >100 in a conditional type.
Fix: Add a depth counter, or refactor to iterate via mapped types instead of recursive conditionals.
Worked example: the OrderID mixed up with UserID
Scenario. Production bug: a refund function received an OrderID parameter but was being called with a UserID. The bug existed for 6 weeks before a customer complained. Both are strings; TypeScript said it was fine.
Novice would: Add a JSDoc @param orderId — must be an order ID, not a user ID and write a runtime check that throws on the wrong shape. Misses two things: docs don't fail builds, and the runtime check fires after the wrong query already ran.
Expert catches:
Brand the IDs at the type system.type OrderID = string & { readonly __brand: 'OrderID' }. Now processRefund(orderId: OrderID) rejects raw strings AND UserIDs at compile time.
Factory that validates.OrderID(s: string): OrderID checks the format (/^o_[a-z0-9]{12}$/) and casts inside. Outside the factory, no as OrderID is allowed (lint rule).
Audit existing call sites.tsc --noEmit after the brand is applied surfaces every place that's passing a raw string. Fix them by reading from typed sources (DB types, API response types) or by routing through the factory.
Test the brand can't be bypassed. A expectTypeOf<UserID>().not.toMatchTypeOf<OrderID>() test (via expect-type or similar) locks the invariant.
Timeline. Novice's runtime guard catches future cases at request-time, not commit-time; the same shape of bug recurs across other ID pairs (CustomerID vs MerchantID, etc). Expert's branded-type fix once applied is propagated to all 14 ID types in the domain via codemod; the entire class of bug disappears, and TypeScript catches violations in IDE before commit.
Quality gates
Test: every discriminated union has an exhaustiveness test that fails to compile if a new variant is added without handling (const _: never = x in the default branch).
Test: branded types have negative type-tests (expectTypeOf or tsd) asserting two different brands are not assignable.
Discriminated unions used for sum types; discriminants are literal types ('a' | 'b'), not booleans or enums.
satisfies preferred over annotation for inferring narrow types. ESLint rule or grep for : SomeWideType = flags candidates.
as audited: every instance has a // reason: comment in the same line. CI grep fails on bare as without comment.
Branded types have validating factories; as Brand outside the factory file is lint-banned.
User-defined type guards return x is Foo, not boolean. Verified by grep for function is.*\(.*\): boolean.
No any in domain code; only in vendor/ or framework-boundary files. CI: tsc --noImplicitAny clean.
Conditional types stay ≤ 50 lines; complex ones broken into named type aliases. ESLint rule on type-alias body length.
tsc --noEmit runs in CI on every PR; build fails on type errors.
Deterministic Audit
Before committing to a type design (or reviewing another agent's), write it as a JSON plan
matching schemas/typescript-narrowing-expert-plan.schema.json and run the deterministic
auditor:
auditTypescriptNarrowingExpert(plan) (in scripts/typescript_narrowing_expert_audit.mjs)
turns this skill's anti-patterns and Quality Gates into machine-checkable rules over
structured fields: a discriminant that isn't a literal (switch won't narrow), a union with
no never exhaustiveness check, a "type guard" that returns boolean instead of x is Foo,
bare as casts without documented reasons, branded IDs with no validating factory or
negative type-tests, narrowing that crosses a closure boundary without a const capture,
over-deep conditional types, and missing tsc --noEmit CI. It returns
{ pass, score, findings, recommendations }. examples/sample-input.json is a
branded-ID + discriminated-union plan that clears every gate (pass: true). Changes are
tracked in CHANGELOG.md.
NOT for
Runtime validation — pair with zod/valibot for data coming from outside.
TypeScript compiler internals — different domain.
Design-time-only types that never affect runtime — use sparingly; they're write-once-read-confused.
JavaScript projects — narrowing requires the type system.