Skip to content

@studnicky/no-mixed-callable-shapes ​

Forbids a union or intersection type from mixing a callable/constructable constituent with a data constituent.

A declaration is a function (and belongs in an interface) or it is data (and belongs in a schema-derived type). TypeScript has no syntax for an interface that is itself a union, so type X = (() => void) | { a: 1 }; has no interface remedy — type-alias-invariants's aliasMustBeInterface advice is impossible to follow for this shape. This rule owns the diagnostic instead and directs a split.

Fixable: No · Options: No · Suggested severity: error

Detection ​

The rule inspects every union and intersection type node in the file, at any nesting depth — inside object type members, array and tuple element types, and type-reference type arguments. A union or intersection mixes shapes when at least one constituent is callable/constructable and at least one other constituent is data:

  • a function type or constructor type (() => void, new () => T) is callable;
  • a type literal with a call, construct, or method signature is callable;
  • a named reference is resolved through its declaration — a callable interface (one with a call or construct signature, including inherited through heritage) or a type alias whose body is callable makes the reference callable;
  • Array, Readonly, and ReadonlyArray are data, regardless of their element type;
  • everything else (object literals, primitives, other named references) is data.

undefined, null, and never constituents are neutral — they never make a union mixed on their own, so (() => void) | undefined is a single optional-callable shape, not a mix.

A purely callable union ((() => void) | (() => number)) or a purely data union ({ a: 1 } | { b: 2 }) is not reported. type-alias-invariants continues to own the purely callable case with its aliasMustBeInterface advice, since converting that shape to an interface is possible.

Function & { readonly 'prototype': TInstance } is not a mixed shape. prototype is an intrinsic member every function value already carries (alongside name and length), so intersecting Function with a literal naming only intrinsic members narrows which function it is rather than bolting data onto a callable — the canonical spelling for a polymorphic static-factory this parameter (static create<TInstance extends X>(this: Function & { readonly 'prototype': TInstance }): TInstance). lexical-this-only recognizes the same factory idiom in static context, so the two rules agree on it. A literal naming any other member — retries, options, anything not already on every function value — is still data riding along with a callable and stays reported.

✗ Incorrect ​

Union of callable and data ​

ts
type MixedType = (() => void) | { a: 1 };

Intersection of data and callable ​

ts
type MixedIntersectionType = { a: 1 } & (() => void);

Named callable reference mixed with data ​

ts
type CallbackType = () => void;
type IndirectMixedType = CallbackType | { a: 1 };

Nested inside a property ​

ts
type NestedMixedType = {
  readonly slot: (() => void) | { a: 1 };
};

✓ Correct ​

ts
type DispatcherType = (() => void) | ((value: string) => void);
ts
type MaybeCallbackType = (() => void) | undefined;
ts
type EitherDataType = { a: 1 } | { b: 2 };
ts
type PolymorphicFactoryType<TInstance> = Function & { readonly 'prototype': TInstance };

Consumers that need either shape split the callable contract and data representation before composing them at the use site.

Rule boundary ​

interfaces-compose-named-types extracts the pure-data portions of a valid contract interface into named entities. When an interface member's type mixes a callable constituent with data, this rule owns the diagnostic instead — interfaces-compose-named-types skips a data constituent that sits inside a mixed union or intersection so the consumer gets one actionable diagnostic, not two contradictory ones.

Configuration ​

The rule takes no options and recognizes no comment, declaration-name, or path exemptions. Enable or disable the complete rule through flat configuration:

js
export default [
  {
    files: ['src/**/*.ts'],
    rules: {
      '@studnicky/no-mixed-callable-shapes': 'error'
    }
  },
  {
    files: ['generated/**/*.ts'],
    rules: {
      '@studnicky/no-mixed-callable-shapes': 'off'
    }
  }
];