Jason Desrosiers
Jason Desrosiers

Reputation: 24399

How to type an array of generic functions in TypeScript

I have something that I'm having trouble typing. I was able to reduce the problem to the following minimal example. I have a function where the first argument is generic and constrained to something that is serializable as JSON.

type Json = string | number | boolean | null | Json[] | { [property: string]: Json };
type Foo<A extends Json> = (n: A) => boolean;

const a: Foo<number> = (n: number) => n + 1 > 0;
const b: Foo<string> = (n: string) => n.endsWith("a");

I need a type definition for an array of these functions where the generic part can be anything that the generic part of Foo allows.

I understand why this doesn't work.

const foos: Foo<Json>[] = [a, b]; // Error

And, any is too loose. It allows values that aren't valid Foos such as this example where the argument to the function is an HTMLElement which is not assignable to Json.

const foos: Foo<any>[] = [a, b, (n: HTMLElement) => true]; // Invalid, but no error

What I need is a union of all the concrete types of Foos that are allowed in this array. However, that union would have to be infinitely long to cover anything that could be equivalent to Json.

type SomeFoo = Foo<string> | Foo<number> | ... | Foo<Record<string, Record<string, [number, string[]]>>> | ...;
const foos: SomeFoo[] = [a, b];

Is there a way to use generics or some other mechanism to avoid this infinite union?

Edit:

In case it helps, here's a slightly more complicated example that's closer to the real-life code I'm trying to type: playground. I have an object with two functions. The return type of the first function needs to match the first argument of the second function.

Edit 2:

This question was refactored significantly based on feedback from the comments, but it's still asking the same fundamental question.

Upvotes: 4

Views: 4849

Answers (1)

jcalz
jcalz

Reputation: 327849

One problem that makes this particular example hard to discuss is that, on the face of it, a value foo of type (n: A) => boolean (where A is some unresolved generic type parameter that extends Json) cannot safely be called. I can't call foo(123) because while 123 extends Json, I would need to know that 123 extends A, which I don't know. A is a mystery to the caller of that function. It's as if the function is saying "I want something but I won't tell you what it is", and unless you use the any type or type assertions, TypeScript's type system won't even let you guess. In what follows I will ignore this problem and just use type assertions, but it would have been nicer if the question had something more motivating (e.g., a type like {val: A, consumer(val: A): void;} with an unknown A would be usable because you could always safely pass the object's val to its own consumer method). Oh well.


Anyway, whenever you find yourself wishing you had an "infinite union type", it's a sign that you want to use existentially-quantified generics. If these existed in TypeScript, you could write something like:

// not valid TS, don't try this
type SomeFooArray = Array< <∃A extends Json> Foo<A> >;

where <∃A extends Json>(...) means "there exists an A assignable to Json for which the enclosed expression holds, but I don't know or care what A is".

Unfortunately, like most languages with generics, TypeScript only directly supports universally-quantified generics, which acts as an infinite intersection, where <A extends Json>(...) means "the enclosed expression holds for all possible A types that are assignable to Json".

There is an open feature request for existential types at microsoft/TypeScript#14466, but who knows if it'll ever be implemented.


One thing about universal-vs-existential quantification, and unions-vs-intersections is that they are duals to each other, and that one thing can be transformed into its dual by changing the role of data producer and data consumer. It therefore turns out that you can emulate existential types by representing them as a Promise-like data structure. Instead of handing you a <∃A extends Json> Foo<A>, I hand you a

type SomeFoo = <R>(cb: <A extends Json>(foo: Foo<A>) => R) => R;

It's a function that holds its Foo in a black box, and you can pass callbacks into it. The function calls the callback with its Foo as a parameter and hands you back the return result. Anything you could do with a <∃A extends Json> Foo<A> could be done with a SomeFoo:

function acceptSomeFooArray(someFooArray: SomeFoo[]) {
    return someFooArray.map(someFoo => someFoo(foo => foo("hmm"))); // boolean, but
    // -------------------------------------------------> ~~~~~
    // '"hmm"' is assignable to the constraint of type 'A', 
    // but 'A' could be instantiated with a different subtype of constraint 'Json'.
}

Oh, darn, there's that error because you cannot safely call it. Fine:

function acceptSomeFooArray(someFooArray: SomeFoo[]) {
    return someFooArray.map(someFoo => someFoo(
        (<A extends Json>(foo: Foo<A>) => foo("hmm" as A))) // assert
    );
}

Anyway, it's easy enough to turn a concrete Foo<A> into a SomeFoo:

const toSomeFoo = <A extends Json>(foo: Foo<A>): SomeFoo => cb => cb(foo);

And so you could pass an array of SomeFoo where an array of <∃A extends Json> Foo<A> is wanted. As long as you can change your data structures, this is a viable solution.


Oh, but you have an existing library whose data structures you can't alter. In that case, it is still possible to use an alternative of switching the producer with the consumer. Instead of trying to come up with a specific type that means "Foo<A> for some A" and apply it to pieces of data, you instead go to any library function which needs to deal with data of this sort and make it generic so it can handle a Foo<A> for all A.

For example, if you have a library function processFooArray that accepts a heterogeneous array of "some" Foo, you can declare it this way:

type DistribFoo<T> = T extends Json ? Foo<T> : never;

// declaration 
declare function processFooArray<A extends readonly Json[]>(
    arr: readonly [...{ [K in keyof A]: DistribFoo<A[K]> }]): [...{ [K in keyof A]: boolean }];

Here, DistribFoo<T> takes any union of T extends Json and distributes Foo<T> across it, so that DistribFoo<string | number> becomes Foo<string> | Foo<number>. This helps in case processFooArray() takes an unordered array type.

And processFooArray() is generic in A, an array type of Json elements. Then the arr value it accepts is a mapped array type. When you call processFooArray(arr) the compiler can infer the A type from arr, and use it to validate that each member of the array is actually a Foo<T> for some T.

Note that I've made the return type an array of boolean values of the same length as the input arr, under the assumption that the function will (somehow!) call each function and map each function to its output.

Let's test it:

const a: Foo<string> = (n) => n.endsWith("a")
const b: Foo<number> = (n) => n < 0.5
const c = (x: HTMLElement) => x.hasAttribute("src")
   
processFooArray([a, b]); // okay
processFooArray([a, b, c]); // error!
// ------------------> ~

Looks good. The compiler is happy with [a, b], but unhappy with [a, b, c].

This approach probably also has its limits, especially if the library functions produce a value of "Foo<A> for some A"; maybe you can figure out what A should be based on the input to the library function, or maybe you just pick a random A, or never, or something. I won't go farther down this road though.

Playground link to code

Upvotes: 5

Related Questions