Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

TypeScript Generics Don't Apply To Previous Function When Curried, How To?

I have a curried version of map that handles promises. It takes two arguments, one at a time. It takes two type parameters to let the caller provide pieces that can't be inferred. However, in practice the caller always has to provide them because the types aren't inferred in the transform function they pass in. The function looks like this:

/**
 * @description
 *   Like `ramda.map`, but handles an iterator that returns a promise (or not).
 *
 * @example
 *   await mapP((x: number) => Promise.resolve(x + 1))([1, 2, 3]) // -> [2, 3, 4]
 */
export const mapP = <T, R>(xf: (value: T) => Promise<R> | R) => (
  data: T[],
): Promise<R[]> => pipe(when(isNil, always([])), map(xf), allP)(data)

Here's me calling it and you can see that the x is no known by the type system.

How can I fix the way I'm writing the function to have the types work (without abandoning currying- I know it would figure it out if not curried)?

like image 555
rjhilgefort Avatar asked Dec 31 '22 07:12

rjhilgefort


1 Answers

I'm going to use the following declaration:

declare const mapP: <T, R>(xf: (value: T) => Promise<R> | R) => (
  data: T[],
) => Promise<R[]>;

which is the same as your version but doesn't worry about implementation. Anyway, you've run into the issue where the following fails to infer the type of x in the callback:

const res = mapP(x => x.name)(data); // error!
// x is unknown ----> ~

It's not completely unreasonable to expect that the compiler might infer that x has to be of type Foo because data is of type Foo[]. There is such a thing as contextual typing in which the compiler will infer types "backwards in time", by looking at how something is used and figuring out what type it should have been declared to be for that to work. Unfortunately it's a bit too much of a stretch for the compiler to do this backwards through multiple function calls. Oh well.


The big appeal of currying, in my opinion, is to be able to partially apply a function and then use that partially-applied function later, like this:

const f = mapP(x => x.name); // error!
// x is unknown --> ~

// later

const res2 = f(data);

In this case, it would be implausible to expect the compiler could know anything useful about x, especially in light of other possible calls like this:

const res3 = f([{ name: false }]);

where x should be a {name: boolean} instead of a Foo. If your intent in x => x.name is that x should be a Foo, you will need to communicate that intent to the compiler, via a type annotation:

const res4 = mapP((x: Foo) => x.name)(data); // okay Promise<string[]>

This is the solution I'd recommend to your question as stated; you are not requiring that the developer manually specify T and R when you call mapP(). Instead, you are annotating the callback's parameter so that the compiler can infer T and R itself, which it does.

Note that you could even get more fancy and communicate "I'd like the callback to apply to anything with a name property and return a value of that type", by using a generic callback:

const g = mapP(<T>(x: { name: T }) => x.name);

const res5 = g(data); // Promise<string[]>;
const res6 = g([{ name: false }]); // Promise<boolean[]>;

And here the compiler can use some of its higher order type inference introduced in TS3.4 to see that g() is itself a generic function.


So, backing up: if your use case is really to immediately consume the partially-applied function by calling it without holding a reference to it, you should probably do that without currying. The "best of both worlds" approach might be an overloaded hybrid function that is both curried and not-curried:

function mapQ<T, R>(xf: (value: T) => Promise<R> | R, data: T[]): Promise<R[]>;
function mapQ<T, R>(xf: (value: T) => Promise<R> | R): (data: T[]) => Promise<R[]>;
function mapQ<T, R>(
  xf: (value: T) => Promise<R> | R,
  data?: T[]
): ((data: T[]) => Promise<R[]>) | Promise<R[]> {
  return data ? mapQ(xf)(data) : mapQ(xf);
}

Then you can use it curried if and only if your intent is to use the function later:

const res7 = mapQ(x => x.name, data); // okay Promise<string[]>
const h = mapQ((x: Foo) => x.name);
const res8 = h(data); // okay Promise<string[]>

Okay, hope that helps; good luck!

Playground link to code

like image 197
jcalz Avatar answered Apr 30 '23 09:04

jcalz