Currying breaks argument type inference, because argument list gets split in two

44 views Asked by At

I have this nice function which turns an object into a select option for me:

type OptionValue = string;
type OptionLabel = string;

export type Option<V extends OptionValue = OptionValue, L extends OptionLabel = OptionLabel> = {
  value: V;
  label: L;
};

type ExtractStrings<T> = Extract<T, string>;

export const toOption =
  <
    ValueKey extends string,
    LabelKey extends string | ((item: ObjectIn) => OptionLabel),
    ObjectIn extends Record<ExtractStrings<ValueKey | LabelKey>, OptionValue>
  >(
    valueKey: ValueKey,
    labelKey: LabelKey,
    objectIn: ObjectIn | null | undefined
  ): Option<string, string> | null  => {
    return null // The implementation is not important here
  };

const myObj = { x: "foo", y: "bar" } as const 

const result = toOption("x", (params) => `${params.x} (${params.y})`, myObj)
const result2 = toOption("x", "y", myObj)

Playground link here

The typings work nicely, and I like how TypeScript enforces these three constrains for me:

  1. ValueKey must exist on ObjectIn
  2. LabelKey must exist on ObjectIn, if it is a string
  3. If LabelKey is a function, its parameter is correctly infered to ObjectIn

I would now love to take the same function, and curry the last parameter - but I run into obvious problems when typing it. If I leave all generic parameters within the outer function, I no longer get type inference for ObjectIn:

export const toOption =
  <
    ValueKey extends string,
    LabelKey extends string | ((item: ObjectIn) => OptionLabel),
    ObjectIn extends Record<ExtractStrings<ValueKey | LabelKey>, OptionValue>
  >(
    valueKey: ValueKey,
    labelKey: LabelKey,
  ) => (objectIn: ObjectIn | null | undefined): Option<string, string> | null  => {
    return null // The implementation is not important here
  };

const myObj = { x: "foo", y: "bar" } as const

const result = toOption("x", (params) => `${params.x} (${params.y})`)(myObj)
//   Property 'y' does not exist on type 'Record<"x", string>' ^^^

Playground link here

And I cannot move the ObjectIn, because then it is not defined when being referenced inside LabelKey:

export const toOption =
  <
    ValueKey extends string,
    LabelKey extends string | ((item: ObjectIn) => OptionLabel),
    //                                ^^^
    //   Cannot find name 'ObjectIn'. Did you mean 'Object'?
  >(
    valueKey: ValueKey,
    labelKey: LabelKey,
  ) => <
    ObjectIn extends Record<ExtractStrings<ValueKey | LabelKey>, OptionValue>
  >(objectIn: ObjectIn | null | undefined): Option<string, string> | null  => {
    return null // The implementation is not important here
  };

Is there something I can do to keep enforcement of the three constrains I specified earlier, while keeping the function curried?

I believe it cannot be done through direct inference like in my non-curried example, since the outer function is independent, and has no possible way to infer anything from an argument that may or may not be later supplied to its returnee. But maybe there is some other mechanism which can achieve this.

I would be fine with a version which would force me to specify the ObjectIn type explicitly when LabelKey extends Function, while still keeping the inference when LabelKey extends string:

// Would throw error when `myObj` is not assignable to `ExplicitlySpecified`
const result = toOption("x", (p: ExplicitlySpecified) => `${p.x} (${p.y})`)(myObj)
const result2 = toOption("x", "y")(myObj)

I got somewhat close, by isolating the constrain into a third function (sigh) - but I don't really like the third function, and now I run into the problem of ObjInConstrain being defined before ValueKey.

What are my options?

Thank you for your time

2

There are 2 answers

2
GLJ On

This may help out - I think your current method may be trying to solve multiple problems: currying (in your case is actually partial application technically) and your implementation.

Instead of having the curry logic within your method, have well defined curry methods that will do it for you. Here is the playground.

First, lets define some curry types and methods.

type FnCurry2<A,B,C> = (s1: A) => (s2: B) => C
type FnCurry3<A,B,C,D> = (s1: A) => (s2: B) => (s3: C) => D

export const curry2 = <A,B,C>(fn: (a: A, b: B) => C): FnCurry2<A,B,C> =>{
  return (a: A) => (b: B) => fn(a, b);
}

export const curry2r = <A,B,C>(fn: (a: A, b: B) => C): FnCurry2<B,A,C> => {
  return (b: B) => (a: A)  => fn(a, b);
}

export const curry3 = <A,B,C,D>(fn: (a: A, b: B, c: C) => D): FnCurry3<A,B,C,D> =>{
  return (a: A) => (b: B) => (c: C) => fn(a, b, c);
}

export const curry3r = <A,B,C,D>(fn: (a: A, b: B, c: C) => D): FnCurry3<C,A,B,D> => {
  return (c: C) => (a: A) => (b: B) => fn(a, b, c);
}

Now lets normalize your method to be applied normally.

export const toOption =
  <
    ValueKey extends string,
    LabelKey extends string | ((item: ObjectIn) => OptionLabel),
    ObjectIn extends Record<ExtractStrings<ValueKey | LabelKey>, OptionValue>
  >(
    valueKey: ValueKey,
    labelKey: LabelKey,
    objIn: ObjectIn | null | undefined
  ): Option<string, string> | null  => {
    return null // The implementation is not important here
  };

At this point, let the curry functions do the work for you!

const toOptionCurried = curry3r(toOption);
const toOptionStringCat = toOptionCurried((params) => `${params.x} (${params.y})`)
const toOptionString = toOptionCurried("y")

const myObj = { x: "foo", y: "bar" } as const 

const result = toOptionStringCat("x")(myObj);
const result2 = toOptionString("x")(myObj)
 
1
Cody Duong On

You are right that direct inference is impossible (at least following the constraints of currying). Given are some examples of why this is, or skip straight to the answer.

Notice the following:

const myObj = { x: "foo", y: "bar" } as const 
const result2 = toOption("x", "z")(myObj)

There is an error here! But it is on myObj, reading as follows:

Argument of type '{ readonly x: "foo"; readonly y: "bar"; }' 
is not assignable to parameter of type 'Record<"x" | "z", string>'.

But notice how the error is on myObj! It is not that z is not applicable to myObj, but rather myObj does not fit into the object shape of {x: string, z: string}. Likewise given the following function

const result = toOption("x", (params) => `${params.x} (${params.y})`)(myObj)

There is no way to constrain the params to myObj. What if someone does something like this toOption("x", (params) => "")? Without running the curry function? How will we validate params?

Answers

I have provided a few possible resolutions, dependent on your use-case/willingness to adopt one of these strategies.

Swap the order of the curried functions

I would be fine with a version which would force me to specify the ObjectIn type explicitly when...

If this is the case, then why not just pass in the ObjectIn in the first place! I'm a strong believer in that your type modelling should follow as your data does. Which means swapping the curry order! Something like this...

export const toOption2 =
  <
    ObjectIn extends Record<string, OptionValue>
  >(
    objectIn: ObjectIn
  ) => (valueKey: keyof ObjectIn, labelKey: keyof ObjectIn | ((p: ObjectIn) => string)): Option<string, string> => {
    return null!
  };

const result1 = toOption2(foobar)("foo", (params) => `${params.foo} ${params.baz}`)
const result2 = toOption2(foobar)("foo", 'baz')

View on Typescript Playground

If you still want to keep old shapes, it is possible to use function overloading to support this. You can even support associative currying. Implementation will of course be complicated by this. Although, there are varying limitations on typing support.

function toOptionSolution<
    ValueKey extends keyof ObjectIn,
    LabelKey extends keyof ObjectIn,
    ObjectIn extends Record<ExtractStrings<ValueKey | LabelKey>, OptionValue>
  >(
    valueKeyOrObjIn: ObjectIn,
    labelKey?: undefined
  ): (valueKey: ValueKey, labelKey: LabelKey | ((p: ObjectIn) => string) ) => void
function toOptionSolution<
    ValueKey extends keyof ObjectIn,
    LabelKey extends keyof ObjectIn,
    ObjectIn extends Record<ExtractStrings<ValueKey | LabelKey>, OptionValue>
  >(
    valueKeyOrObjIn: keyof ObjectIn,
    labelKey: keyof ObjectIn | ((p: ObjectIn) => string | null)
  ): (objIn: ObjectIn) => string | null
function toOptionSolution
  <
    ValueKey extends string,
    LabelKey extends string,
    ObjectIn extends Record<ExtractStrings<ValueKey | LabelKey>, OptionValue>
  >(
    valueKeyOrObjIn: ValueKey | ObjectIn,
    labelKeyOrObjIn: LabelKey | ObjectIn,
  ): any
  
  {
    return null!
  };

const foobar = {foo: 'bar', baz: 'bang'} as const
const notworking1 = toOptionSolution(foobar)("ababa", 'bababa')
const notworking2 = toOptionSolution(foobar)("foo", 'bababa')
const notworking3 = toOptionSolution('foo', 'bang')(foobar)
const working1 = toOptionSolution(foobar)("foo", (params) => `${params.foo} ${params.baz}`)
const working2 = toOptionSolution(foobar)("foo", 'baz')

View on Typescript Playground

Create a curry class

This is a slightly different pattern, inspired from how Java does currying... Or a JS example is like how Jest looks like

expect(sum(1, 2)).toBe(3);

Because a class can be a generic, we can have the ObjectIn type be applied to the class, and all derived functions can use that type. This is also useful because it allows us to do a sort of partial type inference. So pass in the ObjectIn type to the class to store it, then use the toOption method for our currying.

const foobar = {foo: 'bar', baz: 'bang'} as const

class ToOption<ObjectIn extends Record<string, string>> {
  // Has no real properties...
  constructor() {}

  toOption =
  <
    ValueKey extends keyof ObjectIn,
    LabelKey extends keyof ObjectIn | ((item: ObjectIn) => string)
  >(
    valueKey: ValueKey,
    labelKey: LabelKey,
  ) => (objectIn: ObjectIn | null | undefined): string | null => {
    return null!
  }
}

const result = new ToOption<typeof foobar>().toOption('foo', (params) => `${params.foo} ${params.bang}`)(foobar)
const result2 = new ToOption<typeof foobar>().toOption('foo', 'bang')(foobar)

View on TS Playground

As a side note, it may be better if you go down this route to convert entirely to more OOP like code. Or here are some alternative suggestions:

const foobarc = ToOption<typeof foobar>()
// Multiple methods?
const result = foobarc.toOption('foo', 'baz').takeObject(foobar)
// Take in the key/labels in constructor?
const result2 = ToOption<typeof foobar>('foo', 'bar').takeObject(foobar)

You can also have the class extend the Function class to make it callable.

const foobar = {foo: 'bar', baz: 'bang'} as const

// https://stackoverflow.com/a/40878674/17954209
class ExFunc extends Function {
  [x: string]: any
  constructor() {
    super('...args', 'return this.__self__.__call__(...args)')
    var self = this.bind(this)
    this.__self__ = self
    return self
  }
}

interface ToOptionCompact<ObjectIn extends Record<string, string>> {
  (objectIn: ObjectIn): string | null
}
class ToOptionCompact<ObjectIn extends Record<string, string>> extends ExFunc {
  // Has no real properties...
  constructor(valueKey: keyof ObjectIn, labelKey: keyof ObjectIn) {
    super()
  }
  
  __call__ = (objectIn: ObjectIn | null | undefined): string | null => {
    return null!
  }
}

const result1 = new ToOptionCompact<typeof foobar>("foo", "baz")({} as {not: 'foobar'})
const result2 = new ToOptionCompact<typeof foobar>("foo", "baz")(foobar)

View on TS Playground

Explicitly pass type parameters

Worst option for last. Unfortunately this requires you to specify all three parameters, since there is no partial type inferencing (see Proposal: Partial Type Argument Inference #26242). This is left as a trivial exercise, but this is probably most annoying since it requires three parameters in your case, and one possible function type parameter which is hard to annotate inline.