2

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
    TypeScript does not directly support higher kinded types of the sort requested in [microsoft/TypeScript#1213](https://github.com/microsoft/TypeScript/issues/1213); the workarounds are kind of clunky, one of which is shown in [this playground link](https://tsplay.dev/WzGBLw). Does that fully address your question? If so I'll write up an answer explaining; if not, what am I missing? – jcalz Feb 24 '23 at 02:22
  • @jcalz That does address my question, and it's not too bad of a solution tbh. Feel free to add it in as a full answer so other people can reference it in future (that or I could do it for you lol) – Simon Struthers Feb 24 '23 at 04:35
  • @jcalz That said there is a slight typo in your playground link: the second `R extends [infer RH, ...infer RT]` should say `Combinator[K]` instead of `Combinator[K]`. – Simon Struthers Feb 24 '23 at 04:41
  • I will write up an answer but I just copied your `TupleZip` definition and modified it programmatically; if I have a slight typo it's because your version had a slight typo. Could you [edit] yours and I will make the corresponding edit in mine? – jcalz Feb 24 '23 at 14:46

2 Answers2

1

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!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
1

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]

playground

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]

playground

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 of Combinator<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.

geoffrey
  • 2,080
  • 9
  • 13