Currying 会破坏参数类型推断,因为参数列表被一分为二

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

提问人:Michal Kurz 提问时间:6/14/2023 最后编辑:Michal Kurz 更新时间:6/14/2023 访问量:37

问:

我有一个很好的功能,可以将对象变成我的选择选项:

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)

游乐场链接在这里

这些类型工作得很好,我喜欢 TypeScript 为我强制执行这三个约束的方式:

  1. ValueKey必须存在于ObjectIn
  2. LabelKey如果是字符串,则必须存在于 上ObjectIn
  3. 如果 is 函数,则其参数被正确推断为LabelKeyObjectIn

我现在很想采用相同的函数,并整理最后一个参数 - 但是在输入它时遇到了明显的问题。如果我将所有泛型参数都保留在外部函数中,我将不再获得以下类型的推断: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>' ^^^

游乐场链接在这里

而且我不能移动 ,因为这样当它被引用时就没有定义:ObjectInLabelKey

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
  };

我是否可以做些什么来保持我之前指定的三个约束的执行,同时保持函数的库里?

我相信它不能像我的非咖喱例子那样通过直接推理来完成,因为外部函数是独立的,并且没有办法从以后可能或可能不会提供给其返回者的参数中推断出任何东西。但也许还有其他机制可以实现这一目标。

如果一个版本会迫使我在 时明确指定类型,同时在以下情况下仍然保留推理,我会很好:ObjectInLabelKey extends FunctionLabelKey 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)

通过将约束隔离到第三个函数叹息中,我有点接近了 - 但我不太喜欢第三个函数,现在我遇到了之前被定义的问题。ObjInConstrainValueKey

我有哪些选择?

感谢您抽出宝贵时间接受采访 🙂

typescript typescript-generics 类型推理 currying function-signature

评论


答:

1赞 GLJ 6/14/2023 #1

这可能会有所帮助 - 我认为您当前的方法可能正在尝试解决多个问题:咖喱(在您的情况下实际上是技术上的部分应用)和您的实现。

与其在你的方法中拥有咖喱逻辑,不如有明确定义的咖喱方法,为你做这件事。这里是操场

首先,让我们定义一些咖喱类型和方法。

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);
}

现在让我们规范化您的方法以正常应用。

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
  };

在这一点上,让咖喱功能为您完成工作!

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 6/14/2023
不过,这不需要显式输入吗?IE的。 是我必须做的,这太啰嗦了!但是+1,让我想起了Typescript中的这种咖喱模式。IIRC,也许这更适合更简单的咖喱?const toOptionCurried = curry3r<"x", typeof myObj, "y" | ((params: typeof myObj) => string), Option<string, string> | null>(toOption);
0赞 GLJ 6/15/2023
出于某种原因,它不需要为我明确输入,很奇怪。我认为这种方法的棘手部分是在接受多个联合类型方面做了相当多的工作,并且很难推断出实际类型是什么。我必须交换顺序,以便首先应用最后一个参数,以便可以推断第二个参数。
1赞 Cody Duong 6/14/2023 #2

你是对的,直接推理是不可能的(至少在遵循咖喱的约束下)。给出一些例子来说明为什么会这样,或者直接跳到答案。

请注意以下几点:

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

这里有一个错误!但它是 ,阅读如下:myObj

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

但请注意错误是如何打开的!它不是不适用于 ,而是不适合 的对象形状。同样,给定以下函数myObjzmyObjmyObj{x: string, z: string}

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

没有办法将 myObj 限制。如果有人做这样的事情怎么办?不运行咖喱功能?我们将如何验证参数?paramstoOption("x", (params) => "")

答案

我提供了一些可能的解决方案,具体取决于您的用例/采用其中一种策略的意愿。

交换 curried 函数的顺序

我会接受一个版本,它会迫使我在以下情况下明确指定类型......ObjectIn

如果是这样的话,那为什么不首先通过呢!我坚信,您的类型建模应该遵循您的数据。这意味着交换咖喱订单!像这样的东西......ObjectIn

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')

在 Typescript Playground 上查看

如果您仍然想保留旧形状,可以使用函数重载来支持这一点。您甚至可以支持关联咖喱。当然,实施会因此而变得复杂。虽然,在键入支持方面存在不同的限制。

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')

在 Typescript Playground 上查看

创建咖喱类

这是一个略有不同的模式,灵感来自 Java 如何进行咖喱......或者 JS 示例就像 Jest 的样子

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

因为一个类可以是泛型的,所以我们可以将该类型应用于该类,并且所有派生函数都可以使用该类型。这也很有用,因为它允许我们进行某种部分类型推断。因此,将类型传递给类以存储它,然后使用该方法进行咖喱。ObjectInObjectIntoOption

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)

在 TS Playground 上查看

顺便说一句,如果你沿着这条路线完全转换为更像 OOP 的代码可能会更好。或者这里有一些替代建议:

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)

您还可以让类扩展类以使其可调用。Function

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)

在 TS Playground 上查看

显式传递类型参数

最后的最糟糕选择。遗憾的是,这需要您指定所有三个参数,因为没有分部类型推理(请参阅建议:分部类型参数推理 #26242)。这是一个微不足道的练习,但这可能是最烦人的,因为它需要三个参数,以及一个难以内联注释的可能的函数类型参数。

评论

0赞 Michal Kurz 6/14/2023
哇,非常感谢你,你的想法非常有趣。我完全忘记了我可以从 via 推断密钥。可调用类的例子很残酷,我喜欢它!但鉴于其严重性很小,这听起来不像是解决问题的合理方法。交换参数顺序对我来说也不是一种选择 - 我想要部分应用程序的主要原因是我可以将其作为回调传递到对象映射函数中。所以我想我要么放弃标签回调中的推理,要么使用类似于我的加法 fn 调用示例:tsplay.dev/NVRjvNObjInkeyof