typescript-intersection

star 4

Composes TypeScript types using the & intersection operator, combining interfaces, utility types, and object shapes while managing type compatibility, variance, and never-type pitfalls.

paulpas By paulpas schedule Updated 6/4/2026

name: typescript-intersection description: Composes TypeScript types using the & intersection operator, combining interfaces, utility types, and object shapes while managing type compatibility, variance, and never-type pitfalls. license: MIT compatibility: opencode metadata: version: "1.0.0" domain: coding triggers: typescript intersection, & type operator, intersection types, TypeScript type composition, never type conflict, TypeScript extends vs intersection, combining types role: implementation scope: implementation output-format: code content-types: [code, guidance, do-dont, examples] related-skills: typescript-utility-types, typescript-generics, typescript-pattern-matching archetypes: - tactical - generation anti_triggers: - brainstorming - vague ideation - code golf response_profile: verbosity: low directive_strength: high abstraction_level: operational


TypeScript Intersection Type Patterns

Composes and combines TypeScript types using the & intersection operator to create unified types from orthogonal concerns. When loaded, this skill makes the model design type-safe compositions — merging configuration defaults with user overrides, enriching event types with handler metadata, combining trait interfaces for rich domain models, and avoiding silent never type production that causes runtime failures.

TL;DR Checklist

  • Use & only for orthogonal/unrelated type concerns — prefer extends for subtyping relationships
  • Verify no property conflicts would produce never types in the intersection
  • Prefer union | for mutually exclusive alternatives, not intersection &
  • Check readonly and optional property interactions before combining types
  • Limit intersection depth to 2–3 levels maximum — create intermediate type aliases for readability

When to Use

Use this skill when:

  • Combining unrelated type traits into a richer composite (e.g., Auditable & Editable & Serializable = FullRecord)
  • Merging configuration objects where defaults are combined with user-supplied partial overrides
  • Composing event handler signatures that add metadata or context to existing event types
  • Building enriched return types from utility functions that wrap, transform, or augment domain data
  • Creating DTOs (Data Transfer Objects) that aggregate multiple source interfaces into a single response shape

When NOT to Use

Avoid this skill for:

  • Subtyping relationships where one type is a specialization of another — use interface X extends Y instead
  • Mutually exclusive alternatives — use union | (string | number, not string & number)
  • Combining more than 3–4 types in a single intersection — the resulting type becomes unreadable and hard to debug
  • Intersecting types with known property name conflicts (same key, incompatible required types) — this silently produces never

Core Workflow

  1. Determine Relationship Between Types — Classify whether the relationship is subtyping (is-a) or orthogonal composition (has-traits-of). Use extends for inheritance chains and & for independent trait composition.

    // ✅ Subtyping: AdminUser IS-A ExtendedUser which IS-A BaseUser
    interface BaseUser { id: string; name: string; email: string; }
    interface ExtendedUser extends BaseUser { role: 'admin' | 'user'; permissions: string[]; }
    
    // ✅ Composition: FullRecord HAS BOTH auditing and editing traits independently
    interface Auditable { createdAt: Date; createdBy: string; }
    interface Editable { updatedAt?: Date; updatedBy?: string; }
    type FullRecord = Auditable & Editable;
    // Result: { createdAt: Date; createdBy: string; updatedAt?: Date; updatedBy?: string }
    
    // ✅ Composition: Serializable has nothing to do with Auditable or Editable
    interface Serializable { toJSON(): string; fromJSON(json: string): void; }
    type PersistentRecord = FullRecord & Serializable;
    

    Checkpoint: Ask "Does type B inherit from type A, or does the result need traits from both independently?" If independent — use &. If specialization — use extends.

  2. Validate Property Compatibility — Check each property across all participating types. The TypeScript compiler silently produces never when required properties with incompatible types intersect:

    // ✅ Compatible: same name + compatible types → result has the unified type
    type A = { id: string; status: 'active' | 'inactive'; };
    type B = { id: string; priority: number };
    type Combined1 = A & B;
    // Result: { id: string; status: 'active' | 'inactive'; priority: number }
    
    // ❌ Conflict: same required property with incompatible primitive types → never
    type Bad = { x: string } & { x: number };
    // Result: { x: never } — compiles but is impossible to construct!
    
    // ✅ Optional + Required conflict: required type wins, no never produced
    type Safe1 = { x: string } & { x?: number };
    // Result: { x: string } — the required `string` satisfies the optional `number` too
    
    // ❌ Readonly + mutable conflict with incompatible types → readonly never
    type Bad2 = { x: string } & { readonly x: number };
    // Result: { readonly x: never } — most dangerous pattern!
    

    Checkpoint: Run tsc --noEmit and inspect inferred types using IDE hover or type X = ... exploration in a .ts file. Hover over the type alias to verify no hidden never appears.

  3. Compose Using Intersection — Apply the & operator with compatible types. For configuration merging patterns, intersect concrete defaults with Partial<UserConfig>:

    interface DefaultConfig {
      timeout: number;
      retries: number;
      debug: boolean;
      logLevel: 'info' | 'warn' | 'error';
      maxConnections: number;
    }
    
    interface UserConfig {
      timeout?: number;
      apiKey?: string;
      debug?: false;
      customTimeoutBehavior?: 'retry' | 'fail-fast';
    }
    
    // Combine defaults with user-supplied partial config via intersection
    type AppConfig = DefaultConfig & Partial<UserConfig>;
    
    function createAppConfig(overrides: UserConfig): AppConfig {
      const defaults: DefaultConfig = {
        timeout: 30,
        retries: 3,
        debug: false,
        logLevel: 'info',
        maxConnections: 100,
      };
    
      // Build final config: spread defaults, then apply defined overrides
      const entries = Object.entries(overrides).filter(([, v]) => v !== undefined);
      const userPart = Object.fromEntries(entries) as Partial<UserConfig>;
    
      return {
        ...defaults,
        ...userPart,
      } as AppConfig;
    }
    
    // Usage — type-safe overrides enforced by the compiler
    const config: AppConfig = createAppConfig({
      timeout: 60,           // overrides default 30
      apiKey: "secret-key",  // adds new property from UserConfig
      debug: false,          // explicit override of boolean default
    });
    // config has ALL properties: { timeout: 60, retries: 3, debug: false, logLevel: 'info',
    //                              maxConnections: 100, apiKey: "secret-key" }
    

    Checkpoint: Verify the combined type signature matches your mental model. Use type Keys = keyof AppConfig; and inspect — every property from both sides should appear.

  4. Handle Readonly and Variance Interactions — Check how readonly modifiers interact across intersection members. A readonly property from one side carries through even if another side declares it mutable. Combine this with type conflicts for the most dangerous never pattern:

    interface MutableProp { x: number; }
    interface ReadOnlyProp { readonly x: string; }
    
    // ❌ DANGEROUS — Readonly wins AND types conflict → readonly never (deadlock)
    type Deadlock = MutableProp & ReadOnlyProp;
    // Result: { readonly x: never } — compiles successfully but CANNOT be instantiated!
    
    // ✅ GOOD — Ensure property names do not overlap between mutable/readonly interfaces
    interface Auditable {
      readonly createdAt: Date;
      createdBy: string;
    }
    interface Editable {
      updatedAt?: Date;
      updatedBy?: string;
    }
    type FullRecord = Auditable & Editable;
    // Result: { readonly createdAt: Date; createdBy: string; updatedAt?: Date; updatedBy?: string }
    // ✅ No conflicts — different property names, no never produced
    
    // ✅ GOOD — Explicitly annotate mutability when combining
    interface ReadOnlyConfig {
      readonly version: number;
      readonly buildId: string;
    }
    interface WritableDefaults {
      timeout: number;
      debug: boolean;
    }
    type StableConfig = ReadOnlyConfig & WritableDefaults;
    // Result: { readonly version: number; readonly buildId: string; timeout: number; debug: boolean }
    

Implementation Patterns

Pattern 1: Configuration Merge (Defaults + Partial Overrides)

interface DefaultOptions {
  retryCount: number;
  timeoutMs: number;
  loggingLevel: 'info' | 'warn' | 'error';
  maxRetries: number;
  sslEnabled: boolean;
}

interface UserOptions {
  retryCount?: number;
  timeoutMs?: number;
  loggingLevel?: 'debug' | 'info' | 'warn' | 'error';
  customHeader?: string;
  sslEnabled?: boolean;
}

// Intersection of concrete defaults with partial user config
type ResolvedOptions = DefaultOptions & Partial<UserOptions>;

function resolveUserOptions(userOpts: UserOptions): ResolvedOptions {
  const defaults: DefaultOptions = {
    retryCount: 3,
    timeoutMs: 5000,
    loggingLevel: 'info',
    maxRetries: 5,
    sslEnabled: true,
  };

  // Extract only defined values from user config to avoid overwriting with undefined
  const validEntries = Object.entries(userOpts).filter(([, v]) => v !== undefined);
  const userPart = Object.fromEntries(validEntries) as Partial<UserOptions>;

  return { ...defaults, ...userPart };
}

// Type-safe usage — compiler enforces only known keys are accepted
const opts = resolveUserOptions({
  retryCount: 5,                      // overrides default 3
  customHeader: 'x-api-key',          // adds new property from UserOptions
  loggingLevel: 'debug',              // uses extended union type
});
// Inferred ResolvedOptions: { retryCount: 5, timeoutMs: 5000, loggingLevel: 'debug',
//                              maxRetries: 5, sslEnabled: true, customHeader: 'x-api-key' }

Pattern 2: Event Handler Composition with Enrichment

// Base event structure used across the application
interface BaseEvent {
  id: string;
  timestamp: number;
  source: string;
  metadata: Record<string, unknown>;
}

// Mouse-specific event — extends base with mouse properties
interface MouseEvent extends BaseEvent {
  type: 'click' | 'dblclick' | 'contextmenu';
  x: number;
  y: number;
  button: 0 | 1 | 2;
  ctrlKey: boolean;
}

// Keyboard-specific event — extends base with keyboard properties
interface KeyboardEvent extends BaseEvent {
  type: 'keydown' | 'keyup' | 'keypress';
  key: string;
  code: string;
  shiftKey: boolean;
  altKey: boolean;
  metaKey: boolean;
}

// Handler metadata — orthogonal trait, unrelated to event specifics
type EnrichedEvent = BaseEvent & {
  handlerName: string;
  processedAt: number;
  processingDurationMs: number;
};

function wrapMouseEvent(event: MouseEvent): EnrichedEvent {
  return {
    ...event,
    handlerName: 'gesture-handler',
    processedAt: Date.now(),
    processingDurationMs: 0, // populated after processing
  };
}

function wrapKeyboardEvent(event: KeyboardEvent): EnrichedEvent {
  const start = Date.now();
  const enriched: EnrichedEvent = {
    ...event,
    handlerName: 'input-handler',
    processedAt: start,
    processingDurationMs: 0,
  };
  // Access both base and keyboard-specific properties safely
  if (enriched.shiftKey || enriched.altKey || enriched.metaKey) {
    enriched.metadata.modifiers = [
      enriched.shiftKey && 'shift',
      enriched.altKey && 'alt',
      enriched.metaKey && 'meta',
    ].filter(Boolean) as string[];
  }
  enriched.processingDurationMs = Date.now() - start;
  return enriched;
}

// Generic dispatch — works with ANY EnrichedEvent regardless of base type
function dispatchEnriched(event: EnrichedEvent): void {
  console.log(
    `[${event.handlerName}] ${event.id} @ ${new Date(event.timestamp).toISOString()}` +
    ` (${event.processingDurationMs}ms)`
  );
}

// Type inference preserves all properties — no casting needed
const mouseEnriched = wrapMouseEvent({
  id: 'evt-001',
  timestamp: Date.now(),
  source: 'canvas',
  metadata: {},
  type: 'click',
  x: 120,
  y: 340,
  button: 0,
  ctrlKey: false,
});
dispatchEnriched(mouseEnriched); // ✅ Full type safety

Pattern 3: Interface Extension vs Intersection (BAD vs GOOD)

// ❌ BAD — Using & for subtyping creates confusing and inconsistent relationships
interface Animal { name: string; }
type Dog = Animal & { bark(): void };        // Dog "is-a" Animal but uses &? Confusing.
type Cat = Animal & { meow(): void };         // Inconsistent with extends pattern elsewhere

// ❌ BAD — Deep intersection chains are unreadable and hard to debug
type A = { a: string };
type B = { b: number };
type C = { c: boolean };
type D = { d: Date };
type E = { e: string[] };
type F = { f: Record<string, unknown> };
type SuperType = A & B & C & D & E & F;     // Which properties come from where? Nightmare.

// ✅ GOOD — Use interface extension for true subtyping relationships
interface Animal { name: string; }
interface Dog extends Animal { bark(): void; }
interface Cat extends Animal { meow(): void; }

// ✅ GOOD — Create intermediate type aliases for readability
type CoreFeatures = A & B & C;              // First grouping of related traits
type ExtendedFeatures = CoreFeatures & D & E; // Add more traits incrementally
type FinalType = ExtendedFeatures & F;        // Clear hierarchy of composition

Pattern 4: Discriminated Union with Intersection (Advanced)

// Combining discriminated unions with intersection for rich type-safe patterns
type ActionKind = 'create' | 'update' | 'delete';

interface CreatePayload {
  kind: 'create';
  data: Record<string, unknown>;
}

interface UpdatePayload {
  kind: 'update';
  id: string;
  data: Partial<Record<string, unknown>>;
}

interface DeletePayload {
  kind: 'delete';
  id: string;
  softDelete?: boolean;
}

// Intersection with discriminated union — powerful composition pattern
type Action = BaseEvent & (CreatePayload | UpdatePayload | DeletePayload);

function processAction(action: Action): string {
  switch (action.kind) {
    case 'create':
      // TypeScript narrows: action has data, kind === 'create'
      return `Created entity with ${Object.keys(action.data).length} fields`;
    case 'update':
      // TypeScript narrows: action has id and partial data
      return `Updated entity ${action.id} with ${Object.keys(action.data).length} changes`;
    case 'delete':
      // TypeScript narrows: action has id, optional softDelete
      const method = action.softDelete ? 'soft delete' : 'hard delete';
      return `${method} entity ${action.id}`;
  }
}

// Usage — full type safety with discriminator narrowing
const createAction: Action = {
  id: 'act-001',
  timestamp: Date.now(),
  source: 'api',
  metadata: {},
  kind: 'create',
  data: { name: 'new-resource', tags: ['production'] },
};

processAction(createAction); // ✅ TypeScript knows action.data exists here

Constraints

MUST DO

  • Always verify property compatibility before combining types with & — conflicting required properties silently produce never which compiles but fails at runtime
  • Use interface extends for subtyping (is-a relationships) and intersection & for orthogonal composition (has-traits-of)
  • Add JSDoc comments explaining why a particular type was composed via intersection, especially when spanning 3+ types or using advanced patterns
  • Test inferred types with TypeScript IDE hover / Go-to-Definition to catch silent never production before it reaches production
  • Use Partial<T> on user-facing config interfaces to prevent conflicts with required default properties

MUST NOT DO

  • Use & for mutually exclusive alternatives — use union | instead (e.g., 'open' | 'closed', never 'open' & 'closed')
  • Create intersection chains deeper than 3 levels without intermediate type aliases (type Step1 = A & B; type Step2 = Step1 & C;)
  • Combine types with known property name conflicts (same key, incompatible required types) — this produces never silently and is the #1 cause of phantom type errors
  • Confuse & (intersection/composition) with extends (subtype constraint) in function signatures and generic bounds — they have fundamentally different meanings

Common Pitfalls

Silent never Type Production

// ❌ DANGEROUS — Produces { x: never } which compiles but is impossible to instantiate
type NeverTrap = { x: string } & { x: number };
// NeverTrap is a valid type alias, but you CANNOT create an object of this type!
const obj: NeverTrap = { x: "hello" };   // Error: Type 'string' is not assignable to type 'never'

// ✅ GOOD — Use union for mutually exclusive variants instead
type SafeAlternative =
  | { mode: 'text'; value: string }
  | { mode: 'numeric'; value: number };

const textObj: SafeAlternative = { mode: 'text', value: "hello" };   // ✅ Works
const numObj: SafeAlternative = { mode: 'numeric', value: 42 };      // ✅ Works

Optional vs Required Interaction

// Optional property does NOT conflict with required — the required type wins cleanly
interface HasOptionalX { x?: string; extra: boolean; }
interface HasRequiredX { x: number; other: string; }

type Combined = HasOptionalX & HasRequiredX;
// Result: { x: number; extra: boolean; other: string }
// The required `number` overrides the optional `string` — no conflict, no never!
// This is safe and intentional: the combined type demands a number for `x`.

Readonly Conflicts with Type Mismatch

interface MutableProp { value: string; }
interface ReadOnlyProp { readonly value: number; }

type Conflict = MutableProp & ReadOnlyProp;
// Result: { readonly value: never } — both readonly modifier AND type conflict combine
// This is the MOST dangerous pattern because:
// 1. It compiles without errors (no syntax or semantic error)
// 2. You cannot construct any object of this type at runtime
// 3. The `never` may propagate silently to dependent types

// ✅ Mitigation: Use TypeScript's `satisfies` operator or explicit checking
interface SafeMutable { value: number; }     // Make types compatible first
type ConflictFree = SafeMutable & ReadOnlyProp;
// Result: { readonly value: number } — works perfectly!

Output Template

When composing types with intersection, produce:

  1. Individual base types — Show each participating type/interface with its documented purpose and property list
  2. Compatibility analysis — Note which properties overlap between types and how conflicts (if any) are resolved
  3. Intersection definition — The & composition statement with the full resulting type signature shown as a comment
  4. Instantiation example — A concrete usage showing the combined type being constructed and accessed, proving all properties are available
  5. Variance check — Confirm readonly, optional, and generic variance interactions do not produce unexpected never types

Live References

Install via CLI
npx skills add https://github.com/paulpas/agent-skill-router --skill typescript-intersection
Repository Details
star Stars 4
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator