替换通用接口类型参数

Replace Generic Interface Type Parameter

我正在尝试为仿函数映射创建一个通用函数接口,该接口尊重所提供的接口。在下面显示的代码中,我希望 mb 的值是 Maybe<number> 类型,而不是实际类型 Functor<number>.

我确实意识到一种可能的解决方案是向接口添加重载 FMap。我对这个解决方案不满意的原因是我希望这个代码驻留在一个包中,允许用户为 Functor 创建实现,并在使用函数 map 时具有我上面描述的行为.

interface Functor<A> {
  map<B>(fn: (a: A) => B): Functor<B>;
}

interface FMap {
  <A, B>(fn: (a: A) => B, Fa: Functor<A>): Functor<B>;
}

const map: FMap = (fn, Fa) => (
  Fa.map(fn)
);

class Maybe<A> implements Functor<A> {
  constructor(private readonly a: A) {}
  map<B>(fn: (a: A) => B): Maybe<B> {
    return new Maybe<B>(fn(this.a));
  }
}


const sqr = (x: number) => x*x;
const ma = new Maybe(5);
const mb = map(sqr, ma);

我想要一些表达以下语义的方法:

// Theoretical Code

interface PretendFMap {
  <A, B, FA extends Functor<A>>(fn: (a: A) => B, Fa: FA): FA extends (infer F)<A> ? F<B> : never;
}

然而,这不起作用,因为没有类型参数的通用接口不是有效的 TypeScript 类型,即 Functor 等接口需要将类型参数视为类型,Functor 本身不是有效类型。

如果目前没有表达这些语义的方法,我们将不胜感激任何关于需要用户端代码尽可能少的解决方案的建议。

提前感谢您的时间和考虑。

阻碍我们的是,当您尝试将类型变量 F 作为类型参数传递给另一个类型变量 T,例如 T<F>,TS 不会即使您知道 T 实际上是一个通用接口,也允许这样做。

关于此主题的 discussion 可以追溯到 2014 年的 github 问题中,并且它仍然开放,因此 TS 团队可能不会在不久的将来支持它。

此语言功能的术语称为 higher kinded type。使用该搜索关键字,google 带我去了一趟兔子洞。

原来有一个非常聪明的解决方法!

通过利用 TS declaration merging(又名 模块扩充)特性,我们可以有效地定义一个空的 "type store" 接口,它就像一个普通对象持有对其他有用类型的引用。使用这种技术,我们能够克服这个障碍!

我将以您的案例为例来介绍此技术的概念。如果您想深入了解,我在最后包含了一些有用的链接。

这是最终结果的 TS Playground link剧透警告)。肯定会在现场看到它。现在让我们逐步分解(或者我应该说构建它?)。

  1. 首先,让我们声明一个空的TypeStore接口,我们稍后会更新它的内容。
// just think of it as a plain object
interface TypeStore<A> { } // why '<A>'? see below


// example of "declaration merging"
// it's not re-declaring the same interface
// but just adding new members to the interface
// so we can amend-update the interface dynamically
interface TypeStore<A> {
  Foo: Whatever<A>;
  Maybe: Maybe<A>;
}
  1. 让我们也得到 keyof TypeStore。请注意,随着 TypeStore 的内容更新,$keys 也会相应更新。
type $keys = keyof TypeStore<any>
  1. 现在我们使用实用程序类型修补缺失的语言功能 "higher kinded type"。
// the '$' generic param is not just `string` but `string literal`
// think of it as a unique symbol
type HKT<$ extends $keys, A> = TypeStore<A>[$]

// where we mean `Maybe<A>`
// we can instead use:
HKT<'Maybe', A>  // again, 'Maybe' is not string type, it's string literal
  1. 现在我们有了合适的工具,让我们开始构建有用的东西吧。
interface Functor<$ extends $keys, A> {
  map<B>(f: (a: A) => B): HKT<$, B>
}

class Maybe<A> implements Functor<'Maybe', A> {
  constructor(private readonly a: A) {}
  map<B>(f: (a: A) => B): HKT<'Maybe', B> {
    return new Maybe(f(this.a));
  }
}

// HERE's the key!
// You put the freshly declare class back into `TypeStore`
// and give it a string literal key 'Maybe'
interface TypeStore<A> {
  Maybe: Maybe<A>
}
  1. 终于FMap:
// `infer $` is the key here
// remember what blocked us? 
// we cannot "infer Maybe from T" then apply "Maybe<A>"
// but we can "infer $" then apply "HKT<$, A>"!
interface FMap {
  <A, B, FA extends { map: Function }>
  (f: (a: A) => B, fa: FA): FA extends HKT<infer $, A> ? HKT<$, B> : any
}

const map: FMap = (fn, Fa) => Fa.map(fn);

参考

  1. The github discussion on supporting higer kinded type in TS
  2. Entrance to the rabbit hole
  3. Declaration Merging in TS Handbook
  4. SO post on higher kinded type
  5. Medium post by @gcanti, on higher kinded types in TS
  6. fp-ts lib by @gcanti
  7. hkts lib by @pelotom
  8. typeprops lib by @SimonMeskens