Let's say I have a generic interface like the following:
interface Transform<ArgType> {
transformer: (input: string, arg: ArgType) => string;
arg: ArgType;
}
And then I want to apply an array of these Transform
to a string
. How do I define this array of Transform
such that it validates that <ArgType>
is equivalent in both Transform.transformer
and Transform.arg
? I'd like to write something like this:
function append(input: string, arg: string): string {
return input.concat(arg);
}
function repeat(input: string, arg: number): string {
return input.repeat(arg);
}
const transforms = [
{
transformer: append,
arg: " END"
},
{
transformer: repeat,
arg: 4
},
];
function applyTransforms(input: string, transforms: \*what type goes here?*\): string {
for (const transform of transforms) {
input = transform.transformer(input, transform.arg);
}
return input;
}
In this example, what type do I define const transforms
as in order for the type system to validate that each item in the array satisfies the generic Transform<ArgType>
interface?
You can do this using the generic tuple rest parameters (added in TS 3.0).
Explanation
Typescript has really powerful type inference, but usually chooses the loosest types it can. In this case you need to force it to think of your transforms as a Tuple so that each element has it's own type, and then let the inference do the rest.
I did this with mapped types, the one hiccup with this is that Typescript will use all the tuple keys (such as "length"), not just the numeric ones. You just need to force it to only map the numeric ones. Hence the condition:
T[P] extends T[number]
(Using TS 3.0 in the following)
If TypeScript directly supported existential types, I'd tell you to use them. An existential type means something like "all I know is that the type exists, but I don't know or care what it is." Then your
transforms
parameter have a type likeArray< exists A. Transform<A> >
, meaning "an array of things that areTransform<A>
for someA
". There is a suggestion to allow these types in the language, but few languages support this so who knows.You could "give up" and just use
Array<Transform<any>>
, which will work but fail to catch inconsistent cases like this:But as you said you're looking to enforce consistency, even in the absence of existential types. Luckily, there are workarounds, with varying levels of complexity. Here's one:
Let's declare a type function which takes a
T
, and if it aTransform<A>
for someA
, it returnsunknown
(the new top type which matches every value... sounknown & T
is equal toT
for allT
), otherwise it returnsnever
(the bottom type which matches no value... sonever & T
is equal tonever
for allT
):It uses conditional types to calculate that. The idea is that it looks at
transformer
to figure outA
, and then makes sure thatarg
is compatible with thatA
.Now we can type
applyTransforms
as a generic function which only accepts atransforms
parameter which matches an array whose elements of typeT
matchVerifyTransform<T>
:Here we see it working:
If you pass in something inconsistent, you get an error:
The error isn't particularly illuminating: "
[ts] Argument of type '{ transformer: (input: string, arg: number) => string; arg: string; }[]' is not assignable to parameter of type 'never'.
" but at least it's an error.Or, you could realize that if all you're doing is passing
arg
totransformer
, you can make your existential-likeSomeTransform
type like this:and make a
SomeTransform
from anyTransform<A>
you want:And then accept an array of
SomeTransform
instead:See if it works:
And if you try to do it inconsistently:
you get an error which is more reasonable: "
Types of parameters 'arg' and 'arg' are incompatible. Type 'string' is not assignable to type 'number'.
"Okay, hope that helps. Good luck.