Reputation: 186
I want to describe the relationship between elements of tuple in array of tuples via types in TypeScript.
Is this possible?
declare const str: string;
declare const num: number;
function acceptString(str: string) { }
function acceptNumber(num: number) { }
const arr /*: ??? */ = [
[str, acceptString],
[num, acceptNumber],
[str, acceptNumber], // should be error
];
for (const pair of arr) {
const [arg, func] = pair;
func(arg); // should be no error
}
Real-world example: TypeScript Playground link
Upvotes: 2
Views: 305
Reputation: 327994
You're basically asking for something I've been calling correlated record types, for which there is currently no direct support in TypeScript. Even in the case where you can convince the compiler to catch errors in creating such records, it is not really equipped to verify type safety when using one.
One way to implement such types would be with existentially quantified generics which TypeScript does not currently support directly. If it did, you'd be able to describe your array as something like:
type MyRecord<T> = [T, (arg: T)=>void];
type SomeMyRecord = <exists T> MyRecord<T>; // this is not valid syntax
type ArrayOfMyRecords = Array<SomeMyRecord>;
The next best thing might be to allow ArrayOfMyRecords
to itself be a generic type in which each element of the array is strongly typed with its analogous T
value, and a helper function to infer the stronger type:
type MyRecord<T> = [T, (arg: T) => void];
const asMyRecordArray = <A extends any[]>(
a: { [I in keyof A]: MyRecord<A[I]> } | []
) => a as { [I in keyof A]: MyRecord<A[I]> };
This uses infererence from mapped types and mapped tuples. Let's see it in action:
const arr = asMyRecordArray([
[str, acceptString],
[num, acceptNumber],
[str, acceptNumber] // error
]);
// inferred type of arr:
// const arr: [
// [string, (arg: string) => void],
// [number, (arg: number) => void],
// [string, (arg: string) => void]
// ]
Let's fix that:
const arr = asMyRecordArray([
[str, acceptString],
[num, acceptNumber],
[str, acceptString]
]);
// inferred type of arr:
// const arr: [
// [string, (arg: string) => void],
// [number, (arg: number) => void],
// [string, (arg: string) => void]
// ]
So that works well enough to define arr
. But now look what happens when you iterate over it:
// TS3.3+ behavior
for (const pair of arr) {
const [arg, func] = pair;
func(arg); // still error!
}
This is where the lack of support for correlated records burns you. In TypeScript 3.3, support was added for calling unions of function types, but that support does not touch this issue, which is: the compiler treats func
as a union of functions which is completely uncorrelated with the type of arg
. When you call it, the compiler decides that it can only safely accept arguments of type string & number
, which arg
is not (nor is any actual value, since string & number
collapses to never
).
So if you go this way you'll find you need a type assertion to calm the compiler down:
for (const pair of arr) {
const [arg, func] = pair as MyRecord<string | number>;
func(arg); // no error now
func(12345); // no error here either, so not safe
}
One might decide this is the best you can do and to leave it there.
Now, there is a way to encode existential types in TypeScript, but it involves a Promise
-like inversion of control. Before we go down that route, though, ask yourself: what are you going to actually do with a MyRecord<T>
when you don't know T
? The only reasonable thing you can do is to call its first element with its second element. And if so, you can give a more concrete method that just does that without keeping track of T
:
type MyRecord<T> = [T, (arg: T) => void];
type MyUsefulRecord<T> = MyRecord<T> & { callFuncWithArg(): void };
function makeUseful<T>(arg: MyRecord<T>): MyUsefulRecord<T> {
return Object.assign(arg, { callFuncWithArg: () => arg[1](arg[0]) });
}
const asMyUsefulRecordArray = <A extends any[]>(
a: { [I in keyof A]: MyUsefulRecord<A[I]> } | []
) => a as { [I in keyof A]: MyUsefulRecord<A[I]> };
const arr = asMyUsefulRecordArray([
makeUseful([str, acceptString]),
makeUseful([num, acceptNumber]),
makeUseful([str, acceptString])
]);
for (const pair of arr) {
pair.callFuncWithArg(); // okay!
}
Your real-world example could be modified similarly:
function creatify<T, U>(arg: [new () => T, new (x: T) => U]) {
return Object.assign(arg, { create: () => new arg[1](new arg[0]()) });
}
const map = {
[Type.Value1]: creatify([Store1, Form1]),
[Type.Value2]: creatify([Store2, Form2])
};
function createForm(type: Type) {
return map[type].create();
}
Emulating existential types in TypeScript is similar to the above except that it allows you to do absolutely anything to a MyRecord<T>
that can be done if you don't know T
. Since in most cases this is a small set of operations, it's often easier to just support those directly instead.
Okay, hope that helps. Good luck!
Upvotes: 3