| name | typescript-type-patterns |
| description | Guides advanced TypeScript type manipulation: discriminated unions, mapped types, conditional types, template literal types, utility type composition, and type-level programming for library authors.
Use when the user asks about typescript type patterns, discriminated unions, mapped types, conditional types, template literal types, utility types, infer keyword.
Do NOT use when the user asks about basic TypeScript setup (use `typescript-project-setup`), runtime validation (use `typescript-runtime-safety`), JavaScript idioms (use `javascript-idioms`).
|
| license | Apache-2.0 |
| metadata | {"author":"foundry-skills","version":"1.0.0","tags":"typescript best-practices clean-code","category":"software-engineering","subcategory":"languages-runtimes","depends":"","disclaimer":"none","difficulty":"advanced"} |
TypeScript Type Patterns
When to Use
Use this skill when:
- The user asks how to model a domain with multiple variants that share some fields but diverge on others (classic discriminated union territory)
- The user wants to derive types from existing types rather than duplicating them -- mapped types,
Partial, Required, Pick, Omit, and custom variants
- The user needs a type that changes shape based on a type parameter -- conditional types with
extends, infer, and distributive behavior
- The user wants to generate string literal types from combinations or transformations of other literals -- template literal types for event names, CSS properties, route builders, etc.
- The user is authoring a library or shared package and needs strong inference for generic APIs, builder patterns, or plugin systems
- The user asks about the
infer keyword, type-level recursion, variance, covariance, contravariance, or higher-kinded type simulations
- The user wants to compose utility types into reusable type-level functions for DRY type definitions
- The user asks about exhaustiveness checking, narrowing, type guards, assertion functions, or type predicates
Do NOT use this skill when:
- The user needs help configuring
tsconfig.json, choosing a module resolution strategy, or setting up a TypeScript project (use typescript-project-setup)
- The user needs runtime validation -- Zod, Valibot, io-ts, or schema-to-type bridges (use
typescript-runtime-safety)
- The question is primarily about JavaScript patterns, closures, prototypes, or async patterns with no type-system angle (use
javascript-idioms)
- The user is asking about decorators and metadata reflection for frameworks like NestJS (use a framework-specific skill)
- The user is asking about declaration files (.d.ts authoring) for third-party library shims -- that is a distinct publishing concern
Process
1. Identify the Type-System Problem Category
Before writing a single line of types, classify what the user is actually trying to solve. The wrong pattern category wastes effort and produces unmaintainable types.
- Variant modeling -- multiple shapes that share a tag or discriminant field. This calls for discriminated unions, not class hierarchies.
- Transformation -- taking an existing type and changing its shape (all optional, rename keys, pick fields). This calls for mapped types.
- Branching on type parameters -- selecting a result type based on what was passed in. This calls for conditional types.
- String-based APIs -- event names, CSS-in-JS properties, URL patterns, dot-notation paths. This calls for template literal types.
- Generic API inference -- function overloads, builder patterns, plugin registries where the return type depends on argument shapes. This calls for
infer and conditional types together.
- Ask the user: what does the consuming code look like? Work backward from the call site. The ideal type system makes invalid states unrepresentable and requires zero casts at call sites.
2. Design the Discriminated Union Structure (Variant Modeling)
Discriminated unions are the single most impactful pattern for domain modeling. Apply them before considering classes.
- Use a literal string field named
kind, type, or tag as the discriminant. Choose one convention and hold it across the codebase. kind avoids collision with the DOM type property.
- Every variant must carry the discriminant. Union members without the discriminant break exhaustiveness checking.
- Place shared fields on the union level using an intersection if needed, or accept duplication -- TypeScript does not structurally share fields across union members automatically.
- Implement an exhaustiveness helper:
function assertNever(x: never): never { throw new Error("Unhandled variant: " + JSON.stringify(x)); }. Call it in the default branch of every switch on a discriminated union.
- Keep union members shallow. Deeply nested variant objects make narrowing brittle -- prefer flat discriminant fields.
- For unions with 10+ variants, split into sub-unions by domain concept and compose them:
type Event = UserEvent | PaymentEvent | SystemEvent.
- Extract per-variant types with
Extract<MyUnion, { kind: "foo" }> rather than re-declaring them separately.
3. Build Mapped Types for Structural Transformations
Mapped types let you derive new shapes from existing ones. They eliminate duplication between related types.
- The base form is
type MyMapped<T> = { [K in keyof T]: Transform<T[K]> }. Know this signature cold.
- Use
+readonly / -readonly and +? / -? modifiers to add or remove optionality and mutability. The -? modifier is critical for building a Required<T> that strips optionality from nested types.
- Use
as clauses (key remapping) to rename keys: { [K in keyof T as get${Capitalize<string & K>}]: () => T[K] }. This powers getter/setter derivation, event-name generation, and form-field naming.
- Filter keys with an
as clause that conditionally returns never: [K in keyof T as T[K] extends Function ? K : never] extracts only method keys.
- Combine mapped types with template literal types in the
as clause for API surface derivation -- e.g., deriving a watch method signature from a data schema.
- Never use a mapped type when a simple
Pick or Omit is sufficient. Reach for a custom mapped type only when the built-in utilities cannot express the transformation.
- Homomorphic mapped types (those that iterate
keyof T) preserve optionality and readonly modifiers from the source type automatically. Non-homomorphic mapped types (iterating an explicit union) do not -- account for this when designing.
4. Write Conditional Types for Branching Logic
Conditional types make type-level decisions. They are the if statement of the type system.
- Base syntax:
T extends U ? TrueType : FalseType. The extends clause is a structural compatibility check, not equality.
- Distributive conditional types: when
T is a naked type parameter, T extends U ? A : B distributes over unions automatically. string | number extends string ? "yes" : "no" evaluates per-member. To suppress distribution, wrap: [T] extends [U] ? A : B.
- Use
infer inside the extends clause to capture a sub-type: T extends Promise<infer R> ? R : T unwraps a Promise. T extends (arg: infer A) => infer R ? [A, R] : never extracts argument and return types.
- Chain conditional types for multi-branch logic. Keep chains to at most 3-4 levels -- beyond that, extract named helper types with
type aliases.
- Use
NonNullable<T> as T extends null | undefined ? never : T as a model for writing custom filters.
- Recursive conditional types require
extends in the recursive branch and must have a base case. TypeScript enforces a recursion depth limit (approximately 50 levels for most patterns, 100 for tail-recursive-style). For deeply recursive types (path accessors, deep partials), use tail-recursive conditional types where the recursive call is the entire TrueType branch, not nested inside other constructs.
- Always test conditional types with
type Check = Expect<Equal<YourType<Input>, ExpectedOutput>>. Use the type-fest or expect-type testing pattern rather than guessing.
5. Compose Template Literal Types for String APIs
Template literal types allow type-safe string manipulation and are essential for framework APIs.
6. Use infer and Higher-Order Type Patterns for Library APIs
The infer keyword enables type extraction and is the foundation of advanced generic API design.
- Use
infer to unwrap wrappers: Awaited<T>, ReturnType<F>, Parameters<F>, ConstructorParameters<C>, InstanceType<C> are all built on infer. Understand them by re-deriving them.
- For variadic function types, use rest element inference:
T extends (...args: infer A) => infer R. Combined with tuple types, this enables type-safe pipe/compose implementations.
- Simulate higher-kinded types using interface extension for "type constructors": define an
HKT interface with an Out property, then use mapped types over known instances. This is the approach used by fp-ts and effect-ts.
- Builder pattern typing: each builder method should return a new generic type that accumulates the configuration:
type Builder<Config extends Record<string, unknown>> = {
set<K extends string, V>(key: K, val: V): Builder<Config & Record<K, V>>;
build(): Config;
};
Use intersection accumulation (Config & Record<K, V>) to track state without losing prior fields.
- Readonly tuple inference for fixed-arity APIs: use
as const assertions on the argument side and readonly [...T] constraints on the parameter side to infer tuple lengths precisely.
- When designing generic functions, prefer inference over explicit type parameters. If the user must write
fn<string, number>(...), the design has failed -- fn("hello", 42) should infer both.
7. Compose Utility Types and Validate with Type Tests
Good TypeScript codebases treat types as first-class citizens with their own tests.
- Compose utilities in layers:
Prettify<Omit<Partial<T>, "id">> is readable; deeply nested one-liners without aliases are not. Break them into named intermediate types.
- The
Prettify trick -- type Prettify<T> = { [K in keyof T]: T[K] } & {} -- forces TypeScript's display to expand intersections and mapped types into a flat object shape. Use it on exported API types to improve IDE hover text.
- Write type-level unit tests using the
Equal and Expect pattern:
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
type _Tests = [
Expect<Equal<MyUtility<string>, ExpectedString>>,
Expect<Equal<MyUtility<never>, never>>,
];
A compile error on _Tests is a failing type test. This technique requires no runtime and catches regressions.
- Run
tsc --noEmit in CI to catch type errors. Add isolatedModules: true in tsconfig for libraries to verify each file is independently importable.
- Use
@ts-expect-error (not @ts-ignore) to assert that a specific line must produce a type error. If the error disappears, @ts-expect-error itself becomes an error -- catching when your negative test case broke.
- Document complex types with JSDoc
@example blocks showing the input/output transformation. IDEs render these on hover.
Output Format
When delivering TypeScript type pattern guidance, structure the response as follows:
## Problem Classification
[One sentence naming the pattern category: discriminated union / mapped type / conditional type / template literal / infer / composition]
## Type Design
### Source Types
[The "input" types the user has or is modeling]
### Derived / Result Types
[The target types being constructed]
### Implementation
[Complete, copy-pasteable TypeScript code with inline comments]
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Discriminant field name | kind | Avoids DOM collision with `type` |
| Distribution suppression | [T] extends [U] | Needed because T is a union |
| Recursion depth cap | 4 levels | Avoids TS2589 instantiation limit |
| Prettify wrapper | Yes / No | Improves hover text for exported API |
## Usage at Call Sites
[Show 3-5 example usages demonstrating that no casts are needed]
## Type Tests
[Show Expect<Equal<...>> tests covering happy path, edge cases, and never cases]
## Gotchas and Maintenance Notes
[Specific things future maintainers must know about this type design]
Rules
-
Never use type assertions (as) to paper over type errors in application code. If a cast is needed, the type design is wrong. The only legitimate uses of as are: narrowing from unknown after a validated check, casting DOM query results that TypeScript cannot know the shape of, and as const. Every other as is a smell.
-
Distributive conditional types distribute over naked type parameters -- always check whether distribution is the desired behavior. If T = string | number and the type should treat the union as a whole, suppress with [T] extends [U]. Failing to do this is the most common source of surprising type outputs from conditional types.
-
Never use any. Use unknown for values of unknown shape and never for impossible types. any disables type checking bidirectionally. unknown requires narrowing before use. never is the bottom type and propagates through unions correctly. When accepting arbitrary callback results, type the return as unknown, not any.
-
A discriminated union must have an exhaustive switch with an assertNever default. TypeScript narrows in switch statements but does not warn about missing cases unless noImplicitReturns and strictNullChecks are both enabled AND the function return type is explicit. Use assertNever defensively regardless of compiler settings.
-
Recursive types must have an explicit depth limit or a base case that TypeScript can resolve without instantiating the recursive branch. TypeScript throws TS2589 ("Type instantiation is excessively deep") when recursion exceeds approximately 50-100 levels. For path accessor types, limit to 4 levels of nesting. For deep partial/required utilities, document the depth limit in a JSDoc comment.
-
Mapped types that remap keys with as never to filter them must be tested against empty input ({}) and never inputs. A mapped type over never produces {} not never -- this surprises consumers expecting never to propagate.
-
Template literal types on large string-union cross-products can crash the TypeScript language service. A union of 50 event names crossed with 10 modifier prefixes produces 500 members. Above roughly 100,000 instantiations, tsc becomes unusably slow. Decompose large cross-products into sub-unions or switch to string with a branded newtype.
-
Prefer interface over type alias for object shapes that will be extended or augmented. Interfaces support declaration merging and provide better error messages at large scale. Use type for unions, intersections, conditional types, mapped types, and template literals -- these constructs cannot be expressed with interface.
-
Never rely on implicit any inference in generic positions. Configure tsconfig.json with "strict": true (which enables noImplicitAny). In generic functions, if a type parameter cannot be inferred from arguments, add a constraint or a default: <T extends Record<string, unknown> = Record<string, unknown>>. An unconstrained T with no default produces unknown in strict mode, which is correct but may surprise users.
-
Exported types from a library must be tested for variance correctness. A type that is covariant in T (safe to read) behaves differently than one that is contravariant (safe to write) when assignability is checked. Function parameter positions are contravariant. Return positions are covariant. Getting this wrong causes consumers to hit unexpected type errors when substituting subtypes. Use the Expect<Equal<...>> test pattern to lock in assignability behavior across releases.
Edge Cases
Circular / Mutually Recursive Types
TypeScript supports recursive type aliases since 4.1 (for object types) and recursive conditional types with careful tail-recursive patterns. However, two types that are mutually recursive (type A = { b: B }; type B = { a: A }) work for structural types but cause TS2615 in conditional types. Workaround: introduce an intermediate interface that breaks the cycle, since interfaces can be recursive without triggering the conditional-type recursion check. For JSON-like recursive types, use the canonical pattern: type Json = string | number | boolean | null | Json[] | { [key: string]: Json }.
never Propagation Surprises in Unions and Mapped Types
never is the identity element for unions: string | never simplifies to string. This is correct and useful -- but it means a conditional type that produces never for some branches silently removes those branches from a union result. When a mapped type filters keys using as SomeConditional extends ... ? K : never, testing with an all-filtered input produces {} (empty object type) not never. If downstream code checks T extends never, it will not match {}. Handle this explicitly: keyof Result extends never ? never : Result.
TypeScript Version Compatibility for Library Authors
Advanced patterns have version requirements. Template literal types require TS 4.1+. Recursive conditional types with the tail-recursive optimization require TS 4.5+. Variance annotations (in/out modifiers) require TS 4.7+. satisfies operator requires TS 4.9+. const type parameters require TS 5.0+. When authoring a shared library, declare the minimum TypeScript peer dependency in package.json ("peerDependencies": { "typescript": ">=4.7" }) and test against the minimum version in CI using npm install typescript@4.7 in a separate CI matrix entry.
Generic Constraints That Are Too Tight or Too Loose
Over-constraining a generic (T extends { id: string; name: string; status: "active" | "inactive" }) forces callers to have exactly those fields and breaks structural compatibility with subtypes that have additional fields. Under-constraining (T extends object) provides no useful narrowing inside the function. The sweet spot is constraining only the fields the function actually accesses: T extends { id: string }. Use keyof constraints to make mapped utilities work: K extends keyof T ensures T[K] is always valid.
Conditional Types Inside Mapped Types (and Vice Versa)
Combining these two patterns is powerful but has a subtle interaction: a conditional type inside a mapped type's value position is evaluated eagerly only if T is fully resolved. When T is an unresolved generic, TypeScript defers evaluation. This means that type X<T> = { [K in keyof T]: T[K] extends string ? "yes" : "no" } will not simplify until T is known -- which is the desired behavior. But if you expect a concrete output from a generic type before it is instantiated, the type will appear as a deferred conditional in IDE hover text, which confuses users. Mitigate by providing concrete @example types in JSDoc or by creating named alias types for common instantiations.
Exhaustiveness Checking Across Module Boundaries
When a discriminated union is extended in a separate module (adding a new variant), all existing switch statements in other modules silently break exhaustiveness. TypeScript does not re-evaluate exhaustiveness after module-level changes unless the switch is in a function with an explicit return type. Mitigate by: (1) colocating the assertNever call in a utility that also exports the union type, (2) using a central handler registry that maps variant kind strings to handler functions -- a missing handler becomes a type error at registration time, not a runtime surprise.
Performance Impact of Large Structural Types in Monorepos
In a monorepo with 300+ files importing a complex mapped or conditional type, TypeScript's language service can degrade significantly. Symptoms: hover takes 5-10 seconds, autocomplete lags, tsc takes minutes. Diagnosis: run tsc --diagnostics and inspect Instantiation count. If a single type exceeds 100,000 instantiations, refactor it. Solutions: (1) break the type into smaller composable pieces, (2) use interface instead of type alias where possible (interfaces are cached by identity, type aliases by structure), (3) avoid unbounded cross-products in template literal types, (4) add a // @ts-ignore on a test file and compare language service speed to confirm the type is the culprit.
Example
Input from user: "I'm building a form library. I want a type-safe useField hook where useField('email') infers the field value as string, useField('age') infers number, and useField('acceptedTerms') infers boolean. The schema is defined once and the hook should require no explicit type parameters."
Output:
Problem Classification
Generic API inference using a schema-constrained record type with key-indexed return type -- conditional types and indexed access types, no explicit type parameter at call site.
Type Design
Source Types
type FormSchema = {
email: string;
age: number;
acceptedTerms: boolean;
birthDate: Date;
};
Derived / Result Types
type FieldName = keyof FormSchema;
type FieldValue<K extends FieldName> = FormSchema[K];
type FieldState<K extends FieldName> = {
value: FieldValue<K>;
error: string | null;
touched: boolean;
onChange: (next: FieldValue<K>) => void;
onBlur: () => void;
};
Implementation
export type FormSchema = {
email: string;
age: number;
acceptedTerms: boolean;
birthDate: Date;
};
export type FieldName = keyof FormSchema;
export type FieldValue<K extends FieldName> = FormSchema[K];
export type FieldState<K extends FieldName> = {
value: FieldValue<K>;
error: string | null;
touched: boolean;
onChange: (next: FieldValue<K>) => void;
onBlur: () => void;
};
import { useState, useCallback } from "react";
import type { FieldName, FieldValue, FieldState, FormSchema } from "./schema";
type FormStore = FormSchema;
export function useField<K extends FieldName>(
store: FormStore,
fieldName: K,
validate?: (value: FieldValue<K>) => string | null
): FieldState<K> {
const [value, setValue] = useState<FieldValue<K>>(store[fieldName]);
const [error, setError] = useState<string | null>(null);
const [touched, setTouched] = useState(false);
const onChange = useCallback(
(next: FieldValue<K>) => {
setValue(next);
if (validate) {
setError(validate(next));
}
},
[validate]
);
const onBlur = useCallback(() => {
setTouched(true);
}, []);
return { value, error, touched, onChange, onBlur };
}
Making the Schema Generic (Library-Author Extension)
To let library consumers define their own schema, parameterize on Schema:
export function createForm<Schema extends Record<string, unknown>>(
initialValues: Schema
) {
type K = keyof Schema & string;
function useField<F extends K>(
fieldName: F,
validate?: (value: Schema[F]) => string | null
): FieldState<F, Schema> {
}
return { useField };
}
type FieldState<K extends keyof Schema & string, Schema> = {
value: Schema[K];
error: string | null;
touched: boolean;
onChange: (next: Schema[K]) => void;
onBlur: () => void;
};
Usage:
const { useField } = createForm({
email: "",
age: 0,
acceptedTerms: false,
birthDate: new Date(),
});
const emailField = useField("email");
emailField.onChange("user@example.com");
const ageField = useField("age");
ageField.onChange(25);
ageField.onChange("25");
const termsField = useField("acceptedTerms");
termsField.onChange(true);
Key Decisions
| Decision | Choice | Rationale |
|---|
| Schema as a type alias | type FormSchema = {...} | Simple, no class overhead, works directly with keyof |
| Indexed access for value type | Schema[K] | Avoids conditional types entirely -- simpler and faster for TS to evaluate |
| Factory function pattern | createForm(initialValues) | Closes over the schema so useField never needs an explicit type parameter |
K extends keyof Schema & string | Intersect with string | keyof can produce `string |
as const not required | Initial values use type inference | Primitive literals ("", 0, false) infer as string, number, boolean -- exactly what we want. as const would infer "" as the literal type "", making onChange accept only the empty string. |
| Validate callback typed | `(value: Schema[F]) => string | null` |
Usage at Call Sites
const emailField = useField("email");
const ageField = useField("age", (v) => v < 0 ? "Age cannot be negative" : null);
const termsField = useField("acceptedTerms");
const bad = useField("nonexistent");
Type Tests
import type { Equal, Expect } from "@type-challenges/utils";
type Schema = { email: string; age: number; active: boolean };
type _T1 = Expect<Equal<FieldValue<"email">, string>>;
type _T2 = Expect<Equal<FieldValue<"age">, number>>;
type _T3 = Expect<Equal<FieldValue<"active">, boolean>>;
type EmailState = FieldState<"email", Schema>;
type _T4 = Expect<Equal<EmailState["value"], string>>;
type _T5 = Expect<Equal<Parameters<EmailState["onChange"]>[0], string>>;
type _Bad = FieldValue<"missing">;
Gotchas and Maintenance Notes
- Do not add
| undefined to FieldValue<K>. Optional fields (email?: string) produce string | undefined from Schema[K] automatically. Adding | undefined manually would double-widen optional fields.
keyof Schema includes number and symbol keys if present. The & string intersection is not optional -- omitting it causes template literal types and DOM attribute assignments that use field names to accept symbol keys, which causes runtime errors.
- If the schema uses
interface with declaration merging (uncommon but possible), keyof reflects the merged shape at the call site but not at the type definition site. Prefer type aliases for form schemas to avoid this confusion.
- The
createForm factory pattern means one factory call per form. If the same schema is used in multiple components, export the result of createForm from a shared module rather than calling createForm multiple times -- each call creates a new set of inferred types, which TypeScript treats as structurally equivalent but which can produce confusing error messages when mixed.
- Validator return type is
string | null, not string | undefined. This is intentional: null is explicit absence, undefined is accidental absence. Downstream rendering logic uses strict equality (error !== null) which works correctly with null but would miss undefined. Document this contract in the JSDoc of useField.