Serhii Holinei
Serhii Holinei

Reputation: 5864

Typescript: Can't call selectAll of Transition and Selection union: "This expression is not callable."

TypeScript Playground

I use d3 with types definition from @types/d3. I have a method that operates with the union of Selection and Transition. The argument could be either one. Both Selection and Transition types have the selectAll method. But when I try to apply selectAll to the union, I get the following error:

This expression is not callable. Each member of the union type '{ (): Selection<null, undefined, BaseType, unknown>; (selector: null): Selection<null, undefined, BaseType, unknown>; (selector: undefined): Selection<...>; <DescElement extends BaseType, OldDatum>(selector: string): Selection<...>; <DescElement extends BaseType, OldDatum>(selector: ValueFn<...>): Selection<...>; } ...' has signatures, but none of those signatures are compatible with each other.

Here is an example of code that gives this error:

/* Definitions from @types/d3 */
export type BaseType = Element | Document | Window | null;

export type ValueFn<T extends BaseType, Datum, Result> = (this: T, datum: Datum, index: number, groups: T[] | ArrayLike<T>) => Result;

export interface Transition<GElement extends BaseType, Datum, PElement extends BaseType, PDatum> {
    selectAll<DescElement extends BaseType, OldDatum>(selector: string): Transition<DescElement, OldDatum, GElement, Datum>;
    selectAll<DescElement extends BaseType, OldDatum>(selector: ValueFn<GElement, Datum, DescElement[] | ArrayLike<DescElement>>): Transition<DescElement, OldDatum, GElement, Datum>;
}

export interface Selection<GElement extends BaseType, Datum, PElement extends BaseType, PDatum> {
    selectAll(): Selection<null, undefined, GElement, Datum>;
    selectAll(selector: null): Selection<null, undefined, GElement, Datum>;
    selectAll(selector: undefined): Selection<null, undefined, GElement, Datum>;
    selectAll<DescElement extends BaseType, OldDatum>(selector: string): Selection<DescElement, OldDatum, GElement, Datum>;
    selectAll<DescElement extends BaseType, OldDatum>(selector: ValueFn<GElement, Datum, DescElement[] | ArrayLike<DescElement>>): Selection<DescElement, OldDatum, GElement, Datum>;
}

/* My code */
function myFunc(maybeTransition: Selection<BaseType, unknown, BaseType, unknown> | Transition<BaseType, unknown, BaseType, unknown>) {
    const texts = maybeTransition.selectAll('text'); // ERROR: This expression is not callable.
    // do something with texts
}

So the questions are:

  1. Is it possible to fix this issue using existing type definitions?
  2. If the issue can't be fixed without changing Transition or Selection interfaces, how should it be?

Upvotes: 5

Views: 685

Answers (2)

altocumulus
altocumulus

Reputation: 21578

For your function's parameter maybeTransition you are specifying a union type expecting it to be exactly that, a union of both types:

Selection<BaseType, unknown, BaseType, unknown> | Transition<BaseType, unknown, BaseType, unknown>

Unfortunately and totally counter-intuitively, a union type behaves quite differently from what you expect! The documentation tells us that

A union type is a type formed from two or more other types, representing values that may be any one of those types.

So far, so good. However, when it comes to the properties of the united types

TypeScript will only allow an operation if it is valid for every member of the union.

If you think of your interfaces / types as sets of properties this would, mathematically speaking, be an intersection rather than a union. I have always considered this a misnomer. A fact that is also addressed by the documentation in small print:

It might be confusing that a union of types appears to have the intersection of those types’ properties. This is not an accident - the name union comes from type theory. The union number | string is composed by taking the union of the values from each type. Notice that given two sets with corresponding facts about each set, only the intersection of those facts applies to the union of the sets themselves. For example, if we had a room of tall people wearing hats, and another room of Spanish speakers wearing hats, after combining those rooms, the only thing we know about every person is that they must be wearing a hat.

Looking at the type definitions for Selection and Transition you will notice that there are no compatible methods in either type leaving you with an empty type featuring no properties at all. Hence, there is no way of calling a method .selectAll() on that empty type which is basically what the error message is telling you.

There are two ways around that:

  1. Use intersection types. You could modify your type definition to

     Selection<BaseType, unknown, BaseType, unknown> & Transition<BaseType, unknown, BaseType, unknown>
    

Although this will work I would discourage the use because you are blending two distinct interfaces into one type to "just make it work". You are not extending an interface nor are you enriching it, but you are simply mixing in overloaded definitions of the .selectAll() method which feels somewhat sketchy.

  1. Narrow your typing. Preferably, you should keep using the union type and narrow the type down to either a Selection or a Transition when using it. This can be done by employing an instanceof type guard. That way, TypeScript is able to differentiate between both types:
import { Selection, selection, Transition, transition, BaseType } from "d3";

function myFunc(
  maybeTransition: Selection<BaseType, unknown, BaseType, unknown> |
    Transition<BaseType, unknown, BaseType, unknown>
) {
  let texts;
  if (maybeTransition instanceof selection) {          // type guard
    texts = maybeTransition.selectAll("text");         // texts is of type Selection
  } else if (maybeTransition instanceof transition) {  // type guard
    texts = maybeTransition.selectAll("text");         // texts is of type Transition
  }
}

This might look clumsy but it is due to the type-safety of TypeScript itself. Although both Selection as well as Transition are modelled to look alike in Vanilla JavaScript the type-safety of TypeScript sometimes comes at the cost of more verbose code.

You also have to keep in mind that all of the above holds true for the texts variable which is also a union of both types. You will have to deal with that the same way later on in your code. Depending on your overall design it might be worth considering a totally different approach, though.

Upvotes: 6

Matthieu Riegler
Matthieu Riegler

Reputation: 54998

The problem is the type of the 2 methods are incompatible, you need to discriminate them. A union could a solution :

function myFunc(param: { type: 'Selection', selection: Selection<BaseType, unknown, BaseType, unknown> } | { type: 'Transition', transition: Transition<BaseType, unknown, BaseType, unknown> }) {
    if (param.type === 'Selection') {
        const texts = param.selection.selectAll('text');
    } else {
        const texts = param.transition.selectAll('text');
    }
    // do something with texts
}

Playground

Upvotes: 1

Related Questions