It is correct to say that direct inference becomes impossible, at least based on the limitations of currying. Several examples illustrate why this is so, or you may proceed directly to the solution.
Take note of the following:
const myObj = { x: "foo", y: "bar" } as const
const result2 = toOption("x", "z")(myObj)
An error exists here! The issue lies with myObj
, which reads as follows:
Argument of type '{ readonly x: "foo"; readonly y: "bar"; }'
is not assignable to parameter of type 'Record<"x" | "z", string>'.
The error pertains to myObj
, where it's not a matter of z
being incompatible but rather myObj
not fitting the object structure of {x: string, z: string}
. Similarly, consider the following function
const result = toOption("x", (params) => `${params.x} (${params.y})`)(myObj)
There is no way to restrict params
to myObj
. What if someone uses
toOption("x", (params) => "")
without running the curry function? How would we validate params?
Solutions
I have proposed several potential resolutions, depending on your specific scenario/preference for adopting these strategies.
Switch the order of the curried functions
I would be comfortable with a version that requires me to explicitly specify the ObjectIn
type when...
If that's the case, why not simply pass in the ObjectIn
from the start! I strongly believe that your type model should align with your data, hence reversing 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 maintain old shapes, function overloading can be used to support this. It even supports associative currying, although this complicates implementation and has typing support limitations.
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 approach differs slightly, taking inspiration from how Java handles currying... An example in JavaScript is how Jest operates
expect(sum(1, 2)).toBe(3);
Since a class can be generic, we can apply the ObjectIn
type to the class, allowing all derived functions to utilize that type. This also enables a form of partial type inference. Simply supply the ObjectIn
type to the class to store it, then use the toOption
method for our currying needs.
const foobar = {foo: 'bar', baz: 'bang'} as const
class ToOption<ObjectIn extends Record<string, string>> {
// 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 an alternative, it might be beneficial to transition entirely to more OOP-style code if pursuing this path. Alternatively, consider these suggestions:
const foobarc = ToOption<typeof foobar>()
// Multiple methods?
const result = foobarc.toOption('foo', 'baz').takeObject(foobar)
// Pass key/labels in constructor?
const result2 = ToOption<typeof foobar>('foo', 'bar').takeObject(foobar)
The class can also extend the Function
class to enable callability.
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 {
// 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
Last resort - unfortunately, this necessitates specifying all three parameters since partial type inferencing isn't supported (reference: Proposal: Partial Type Argument Inference #26242). While left as a straightforward exercise, this option may prove cumbersome, requiring three parameters in your case, along with a potentially tricky inline annotation for function types.