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.