英文:
Pass a generic type as a type parameter
问题
I'm writing a utility type for combining two TypeScript lists together:
type TupleIntersect<L extends unknown[], R extends unknown[]> =
L extends [infer LH, ...infer LT]
? R extends [infer RH, ...infer RT]
? [LH & RH, ...TupleIntersect<LT, RT>]
: [LH & R[number], ...TupleIntersect<LT, R>]
: R extends [infer RH, ...infer RT]
? [RH & L[number], ...TupleIntersect<L, RT>]
: L extends []
? R extends []
? []
: R[number]
: R extends []
? L[number]
: (L[number] & R[number])[];
and would like to find a way to generalize it so that I wouldn't have to rewrite the above cases over and over for things like "tuple unions" or other sorts of combinations involving tuple types.
I'd imagine it would have to look something like below:
type TupleZip<L extends unknown[], R extends unknown[], Combinator, Default> =
L extends [infer LH, ...infer LT]
? R extends [infer RH, ...infer RT]
? [Combinator<LH, RH>, ...TupleZip<LT, RT, Combinator, Default>]
: [Combinator<LH, R[number]>, ...TupleZip<LT, R, Combinator, Default>]
: R extends [infer RH, ...infer RT]
? [Combinator<L[number], R>, ...TupleZip<L, RT, Combinator, Default>]
: L extends []
? R extends []
? []
: [Combinator<Default, R[number]>]
: R extends []
? [Combinator<L[number], Default>]
: Combinator<L[number], R[number]>[];
where a Combinator
generic type would be specified like this:
type Intersect<L, R> = L & R;
type TupleIntersect<L extends unknown[], R extends unknown[]> = TupleZip<L, R, Intersect, unknown>;
type Union<L, R> = L | R;
type TupleIntersect<L extends unknown[], R extends unknown[]> = TupleZip<L, R, Union, never>;
but the TypeScript compiler doesn't allow it because "type parameters aren't generic types".
Is there some legal way to accomplish what I'm thinking of doing via TypeScript? Or have I gone too far down the dark path in equivocating TypeScript generic types with C++ template metaprogramming and template templates?
英文:
I'm writing a utility type for combining two TypeScript lists together:
type TupleIntersect<L extends unknown[], R extends unknown[]> =
L extends [infer LH, ...infer LT]
? R extends [infer RH, ...infer RT]
? [LH & RH, ...TupleIntersect<LT, RT>] // Left & right are tuples w/ 1+ element
: [LH & R[number], ...TupleIntersect<LT, R>] // Left is a tuple w/ 1+ element
: R extends [infer RH, ...infer RT]
? [RH & L[number], ...TupleIntersect<L, RT>] // Right is a tuple w/ 1+ element
: L extends []
? R extends []
? [] // Left & right are empty tuples
: R[number] // Left is an empty tuple
: R extends []
? L[number] // Right is an empty tuple
: (L[number] & R[number])[]; // Left & right are list types
and would like to find a way to generalize it so that I wouldn't have to rewrite the above cases over and over for things like "tuple unions" or other sorts of combinations involving tuple types.
I'd imagine it would have to look something like below:
type TupleZip<L extends unknown[], R extends unknown[], Combinator, Default> =
L extends [infer LH, ...infer LT]
? R extends [infer RH, ...infer RT]
? [Combinator<LH, RH>, ...TupleZip<LT, RT, Combinator, Default>]
: [Combinator<LH, R[number]>, ...TupleZip<LT, R, Combinator, Default>]
: R extends [infer RH, ...infer RT]
? [Combinator<L[number], R>, ...TupleZip<L, RT, Combinator, Default>]
: L extends []
? R extends []
? []
: [Combinator<Default, R[number]>]
: R extends []
? [Combinator<L[number], Default>]
: Combinator<L[number], R[number]>[];
where a Combinator
generic type would be specified like this:
type Intersect<L, R> = L & R;
type TupleIntersect<L extends unknown[], R extends unknown[]> = TupleZip<L, R, Intersect, unknown>;
type Union<L, R> = L | R;
type TupleIntersect<L extends unknown[], R extends unknown[]> = TupleZip<L, R, Union, never>;
but the TypeScript compiler doesn't allow it b/c "type parameters aren't generic types".
Is there some legal way to accomplish what I'm thinking of doing via TypeScript? Or have I gone too far down the dark path in equivocating TypeScript generic types with C++ template metaprogramming and template templates?
答案1
得分: 1
TypeScript缺乏对higher kinded types的直接支持,其中泛型类型参数本身可以是泛型。关于这个特性,已经存在一个长期的开放问题microsoft/TypeScript#1213,但到目前为止还没有被实现。
这个问题提到了一些解决方法,其中之一是创建一个“注册表”接口,将你打算合并的higher kinded types合并到其中,然后通过接口键而不是名称来引用它们。所以,代替于:
type Intersect<L, R> = L & R;
type TupleIntersect<L extends unknown[], R extends unknown[]> =
TupleZip<L, R, Intersect, unknown>;
type Union<L, R> = L | R;
type TupleIntersect<L extends unknown[], R extends unknown[]> =
TupleZip<L, R, Union, never>;
你将会有:
interface Combinator<L, R> {
Intersect: L & R;
}
type TupleIntersect<L extends unknown[], R extends unknown[]> =
TupleZip<L, R, "Intersect", unknown>;
interface Combinator<L, R> {
Union: L | R;
}
type TupleUnion<L extends unknown[], R extends unknown[]> =
TupleZip<L, R, "Union", never>;
这意味着你的TupleZip
定义需要将其Combinator
类型参数声明更改为K extends keyof Combinator<any, any>
,然后对Combinator<LH, RH>
的任何引用将变为Combinator<LH, RH>[K]
。就像这样:
type TupleZip<L extends unknown[], R extends unknown[], K extends keyof Combinator<any, any>, Default> =
L extends [infer LH, ...infer LT]
? R extends [infer RH, ...infer RT]
? [Combinator<LH, RH>[K], ...TupleZip<LT, RT, K, Default>]
: [Combinator<LH, R[number]>[K], ...TupleZip<LT, R, K, Default>]
: R extends [infer RH, ...infer RT]
? [Combinator[L[number], RH][K], ...TupleZip<L, RT, K, Default>]
: L extends []
? R extends []
? []
: [Combinator<Default, R>[K]]
: R extends []
? [Combinator<L, Default>[K]]
: Combinator<L[number], R[number]>[K][];
你可以创建一个ApplyCombinator
实用类型,将其放在更接近正常顺序的东西中,但对于这个示例来说可能有些过于复杂:
type ApplyCombinator<K extends keyof Combinator<any, any>, L, R> =
Combinator<L, R>[K];
type Demo = ApplyCombinator<"Intersect", Foo, Bar>;
// type Demo = Foo & Bar
无论如何,让我们来测试一下:
type I = TupleIntersect<[Foo, Bar], [Baz, Qux]>;
// type I = [Foo & Baz, Bar & Qux]
type U = TupleUnion<[Foo, Bar], [Baz, Qux]>;
// type U = [Foo | Baz, Bar | Qux]
看起来不错!
英文:
TypeScript lacks direct support higher kinded types, where generic type parameters can themselves be generic. There is a longstanding open issue at microsoft/TypeScript#1213 asking for such a feature, but so far it has not been implemented.
That issue mentions several workarounds, one of which is to have a "registry" interface into which you merge your intended higher kinded types, and then refer to them by the interface key instead of by name. So instead of
type Intersect<L, R> = L & R;
type TupleIntersect<L extends unknown[], R extends unknown[]> =
TupleZip<L, R, Intersect, unknown>;
type Union<L, R> = L | R;
type TupleIntersect<L extends unknown[], R extends unknown[]> =
TupleZip<L, R, Union, never>;
you'd have
interface Combinator<L, R> {
Intersect: L & R;
}
type TupleIntersect<L extends unknown[], R extends unknown[]> =
TupleZip<L, R, "Intersect", unknown>;
interface Combinator<L, R> {
Union: L | R
}
type TupleUnion<L extends unknown[], R extends unknown[]> =
TupleZip<L, R, "Union", never>;
That means your TupleZip
definition would need to change its Combinator
type parameter declaration to K extends keyof Combinator<any, any>
, and then any reference to Combinator<LH, RH>
becomes Combinator<LH, RH>[K]
. Like this:
type TupleZip<L extends unknown[], R extends unknown[],
K extends keyof Combinator<any, any>,
Default> =
L extends [infer LH, ...infer LT]
? R extends [infer RH, ...infer RT]
? [Combinator<LH, RH>[K], ...TupleZip<LT, RT, K, Default>]
: [Combinator<LH, R[number]>[K], ...TupleZip<LT, R, K, Default>]
: R extends [infer RH, ...infer RT]
? [Combinator<L[number], RH>[K], ...TupleZip<L, RT, K, Default>]
: L extends []
? R extends []
? []
: [Combinator<Default, R>[K]]
: R extends []
? [Combinator<L, Default>[K]]
: Combinator<L[number], R[number]>[K][];
You could make an ApplyCombinator
utility type that puts it in something closer to the normal order, but it's probably overkill for this example:
type ApplyCombinator<K extends keyof Combinator<any, any>, L, R> =
Combinator<L, R>[K];
type Demo = ApplyCombinator<"Intersect", Foo, Bar>
// type Demo = Foo & Bar
Anyway, let's test it out:
type I = TupleIntersect<[Foo, Bar], [Baz, Qux]>;
// type I = [Foo & Baz, Bar & Qux]
type U = TupleUnion<[Foo, Bar], [Baz, Qux]>;
// type U = [Foo | Baz, Bar | Qux]
Looks good!
答案2
得分: 1
以下是代码部分的翻译:
import { Lift, $Intersect, $Unionize } from 'free-types';
type Foo = { a: 1 };
type Bar = { b: 2 };
type Baz = { c: 3 };
type Qux = { d: 4 };
type I = Lift<$Intersect, [[Foo, Bar], [Baz, Qux]]>;
// type I = [Foo & Baz, Bar & Qux]
type U = Lift<$Unionize, [[Foo, Bar], [Baz, Qux]]>;
// type U = [Foo | Baz, Bar | Qux]
现在以下是其他的部分,不包括代码部分:
这是有关使用“free-types”的一些内容,该库支持一定程度的类型约束。如果您想重新实现TupleZip
、$Unionize
和$Intersect
,可以像这样做:
import { Type, apply } from 'free-types';
// 这是我们的合同。这里很简单:2个参数
export type $Combinator = Type<2>;
export type TupleZip<L extends unknown[], R extends unknown[], $C extends $Combinator, Default> =
L extends [infer LH, ...infer LT]
? R extends [infer RH, ...infer RT]
? [apply<$C, [LH, RH]>, ...TupleZip<LT, RT, $C, Default>]
: [apply<$C, [LH, R[number]]>, ...TupleZip<LT, R, $C, Default>]
: R extends [infer RH, ...infer RT]
? [apply<$C, [L[number], RH]>, ...TupleZip<L, RT, $C, Default>]
: L extends []
? R extends []
? []
: [apply<$C, [Default, R]>]
: R extends []
? [apply<$C, [L, Default]>]
: apply<$C, [L[number], R[number]>[];
// 我们的组合器也扩展了合同
interface $Intersect extends $Combinator { type: A<this> & B<this> }
interface $Unionize extends $Combinator { type: A<this> | B<this> }
type TupleIntersect<L extends unknown[], R extends unknown[]> = TupleZip<L, R, $Intersect, unknown>;
type TupleUnion<L extends unknown[], R extends unknown[]> = TupleZip<L, R, $Unionize, never>;
type Foo = { a: 1 };
type Bar = { b: 2 };
type Baz = { c: 3 };
type Qux = { d: 4 };
type I = TupleIntersect<[Foo, Bar], [Baz, Qux]>;
// type I = [Foo & Baz, Bar & Qux]
type U = TupleUnion<[Foo, Bar], [Baz, Qux]>;
// type U = [Foo | Baz, Bar | Qux]
这种方法相对于接口合并和模块增强有一些优势:
-
传递魔术字符串有点奇怪:用户需要查看
TupleZip
的类型定义,以猜测它在哪里使用,并且可能会感到失望,因为Combinator<any, any>
的变体可以在任何文件中定义,因为接口具有全局状态,这是由于接口合并和模块增强。另一方面,直接传递类型构造函数更类似于高阶函数:更熟悉,无状态,自包含,用户会直接导入它或在需要时定义它。 -
合同可以包括显式的返回类型,而索引接口只会检查返回类型是否是在使用它的地方合法的值(例如,在扩展表达式中,它需要是一个数组)。使用自由类型的用户拥有其合同,并颠倒了我们习惯的依赖关系。
-
在跨包工作时:外部包未加载到IDE工作区,因此当您使用接口合并时,语言服务器无法检查类型最终将在其使用的地方。类型检查被延迟到整个项目编译完毕。而另一方面,合同将携带进行类型检查所需的所有信息,以在自由类型的定义位置进行检查。
英文:
What you are trying to do is already built in free-types
import { Lift, $Intersect, $Unionize } from 'free-types';
type Foo = { a: 1 };
type Bar = { b: 2 };
type Baz = { c: 3 };
type Qux = { d: 4 };
type I = Lift<$Intersect, [[Foo, Bar], [Baz, Qux]]>
// type I = [Foo & Baz, Bar & Qux]
type U = Lift<$Unionize, [[Foo, Bar], [Baz, Qux]]>
// type U = [Foo | Baz, Bar | Qux]
I also wanted to update jcalz' answer: the declaration merging solution is no longer used. To my knowledge the only popular package still using this pattern is fp-ts, because it has many users relying on it, but even this is changing in the new version fp-ts/core.
The most popular pattern is to intersect an interface with an object, so that the this
keyword in the interface declaration references the members of the object it was intersected with. This removes the need for registering types in as many interfaces as you need type parameters variants, passing strings as parameter, using keyof, etc.
A simple implementation
type Type = { [k: number]: unknown, type: unknown }
type apply<$T extends Type, Args> = ($T & Args)['type'];
interface $Union extends Type { type: this[0] | this[1] }
interface $Intersection extends Type { type: this[0] & this[1] }
type A = { a: 1 };
type B = { b: 2 };
type UnionAB = apply<$Union, [A, B]> // { a: 1 } | { b: 2 }
type IntersectionAB = apply<$Intersection, [A, B]> // { a: 1 } & { b: 2 }
Now, using free-types, which supports type constraints to an extent, if you wanted to re-implement TupleZip
, $Unionize
and $Intersect
, you would do it like so:
import { Type, apply } from 'free-types'
// That's our contract. Here it's simple: 2 parameters
export type $Combinator = Type<2>;
export type TupleZip<L extends unknown[], R extends unknown[], $C extends $Combinator, Default> =
// We're constraining with the contract ^^^^^^^^^^^^^^^^^^^
L extends [infer LH, ...infer LT]
? R extends [infer RH, ...infer RT]
? [apply<$C, [LH, RH]>, ...TupleZip<LT, RT, $C, Default>]
: [apply<$C, [LH, R[number]]>, ...TupleZip<LT, R, $C, Default>]
: R extends [infer RH, ...infer RT]
? [apply<$C, [L[number], RH]>, ...TupleZip<L, RT, $C, Default>]
: L extends []
? R extends []
? []
: [apply<$C, [Default, R]>]
: R extends []
? [apply<$C, [L, Default]>]
: apply<$C, [L[number], R[number]]>[];
import { $Combinator, TupleZip } from './TupleZip';
import { A, B } from 'free-types'
// Our combinators also extend the contract
interface $Intersect extends $Combinator { type: A<this> & B<this> }
interface $Unionize extends $Combinator { type: A<this> | B<this> }
type TupleIntersect<L extends unknown[], R extends unknown[]> = TupleZip<L, R, $Intersect, unknown>;
type TupleUnion<L extends unknown[], R extends unknown[]> = TupleZip<L, R, $Unionize, never>;
type Foo = { a: 1 };
type Bar = { b: 2 };
type Baz = { c: 3 };
type Qux = { d: 4 };
type I = TupleIntersect<[Foo, Bar], [Baz, Qux]>;
// type I = [Foo & Baz, Bar & Qux]
type U = TupleUnion<[Foo, Bar], [Baz, Qux]>;
// type U = [Foo | Baz, Bar | Qux]
A few advantages of this approach over interface merging and module augmentation:
-
Passing a magic string is a little weird: a user would need to look up the type definition of
TupleZip
to try to guess what it is doing with it and would be disappointed because variants ofCombinator<any, any>
could be defined in any file since an interface has global state because of interface merging and module augmentation. On the other hand, passing a type constructor directly is more akin to higher order functions: it is more familiar, stateless, self-contained and a user would import it directly or define it where they need it. -
A contract can include an explicit return type, whereas indexing an interface will only check that the return type is a legal value where it is used (for example in a spread expression it will need to be an array). The user of the free type owns its contract and inverts dependencies the way we are used to.
-
When working across packages: foreign packages are not loaded into the IDE workspace, so when you use interface merging the language server cannot check the place were your type will eventually be used. Type checking is deferred until the entire project is compiled.
tsc --watch
also will have to be restarted if the package using your type is installed or updated while it is running. On the other hand, a contract will carry all the information required for type checking to take place at the definition site of your free type.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论