Daniel Wolf
Daniel Wolf

Reputation: 13643

Conditional type infers type that is too wide when using intersection types

Note:

The following code defines a type ListBuilder<TItem> that works similarly to a StringBuilder. Note how the add() method supports several overloads, distinguished using the representation parameter.

type ListBuilder<TItem> = {
  add(item: TItem): void,
  add(representation: 'item', item: TItem): void,
  add(representation: 'list', items: TItem[]): void,
  
  get(): TItem[];
};

The type DynamicListBuilder<TItem> extends ListBuilder<TItem>, adding two more overloads:

type DynamicListBuilder<TItem> = ListBuilder<TItem> & {
  add(representation: 'itemFactory', factory: () => TItem): void,
  add(representation: 'listFactory', factory: () => TItem[]): void,
};

Now let's add a utility type ItemFor<TListBuilder> that gives us the TItem type for a given list builder type:

type ItemFor<TListBuilder> = TListBuilder extends ListBuilder<infer TItem> ? TItem : never;

This utility type doesn't work as expected. The types it infers are too wide:

declare const myListBuilder: DynamicListBuilder<number>;

// Expected: number
// Actual:   number | "list" | (() => number)
type Inferred = ItemFor<typeof myListBuilder>;

// This assignment shouldn't compile, but it does
const test: ListBuilder<number | "list" | (() => number)> = myListBuilder;

test.add(() => 42); // This method call shouldn't compile, but it does

Why is this? How can I get the ItemFor<TListBuilder> type to work as expected?

Upvotes: 1

Views: 43

Answers (1)

This is because how you defined ListBuilder.

There is a big difference between

type ListBuilder<TItem> = {
  add(item: TItem): void,  
  get(): TItem[];
};

AND

type ListBuilder<TItem> = {
  add:(item: TItem) => void,  
  get:() => TItem[];
};

First example uses method syntax and methods are bivariant in TypeScript. This is by design. It is for easier migrating from js to ts.

The second example add:(item: TItem) => void uses function notation. This is much safer to use arrow function notation than methods.

COnsider this example

type Overloading<T> =
  & ((item: T) => void)
  & ((representation: 'item', item: T) => void)
  & ((representation: 'list', items: T[]) => void)

type ListBuilder<T> = {
  add: Overloading<T>
  get(): T[];
};

type DynamicListBuilder<TItem> = ListBuilder<TItem> & {
  add(representation: 'itemFactory', factory: () => TItem): void,
  add(representation: 'listFactory', factory: () => TItem[]): void,
};

type ItemFor<TListBuilder> = TListBuilder extends ListBuilder<infer TItem> ? TItem : never;

declare const myListBuilder: DynamicListBuilder<number>;

// Expected: number
type Inferred = ItemFor<typeof myListBuilder>;

// Error
const test: ListBuilder<number | "list" | (() => number)> = myListBuilder;

type O<T> = T extends DynamicListBuilder<number> ? true : false
type Result = O<ListBuilder<number | "list" | (() => number)>>

test.add(() => 42);

Playground

test has failed, type Inferred infered as a number.

Try to avoid this syntax: add(item: TItem): void.

As you might have noticed, I have overloaded add method in other way. Intersection of function produces overloads.

Here you can find more about bivariance

Under --strictFunctionTypes the first assignment is still permitted if compare was declared as a method. Effectively, T is bivariant in Comparer because it is used only in method parameter positions.

interface Comparer<T> {
  compare(a: T, b: T): number;
}
declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;
animalComparer = dogComparer; // Ok because of bivariance
dogComparer = animalComparer; // Ok

enter image description here

Upvotes: 1

Related Questions