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.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
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.