Let\'s say I have a generic interface like the following:
interface Transform {
transformer: (input: string, arg: ArgType) => string;
a
(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 like Array< exists A. Transform >
, meaning "an array of things that are Transform
for some A
". 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
, which will work but fail to catch inconsistent cases like this:
applyTransforms("hey", [{transformer: repeat, arg: "oops"}]); // no error
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 a Transform
for some A
, it returns unknown
(the new top type which matches every value... so unknown & T
is equal to T
for all T
), otherwise it returns never
(the bottom type which matches no value... so never & T
is equal to never
for all T
):
type VerifyTransform = unknown extends
(T extends { transformer: (input: string, arg: infer A) => string } ?
T extends { arg: A } ? never : unknown : unknown
) ? never : unknown
It uses conditional types to calculate that. The idea is that it looks at transformer
to figure out A
, and then makes sure that arg
is compatible with that A
.
Now we can type applyTransforms
as a generic function which only accepts a transforms
parameter which matches an array whose elements of type T
match VerifyTransform
:
function applyTransforms>(
input: string,
transforms: Array & VerifyTransform
): string {
for (const transform of transforms) {
input = transform.transformer(input, transform.arg);
}
return input;
}
Here we see it working:
applyTransforms("hey", transforms); // okay
If you pass in something inconsistent, you get an error:
applyTransforms("hey", [{transformer: repeat, arg: "oops"}]); // 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
to transformer
, you can make your existential-like SomeTransform
type like this:
interface SomeTransform {
transformerWithArg: (input: string) => string;
}
and make a SomeTransform
from any Transform
you want:
const makeSome = (transform: Transform): SomeTransform => ({
transformerWithArg: (input: string) => transform.transformer(input, transform.arg)
});
And then accept an array of SomeTransform
instead:
function applySomeTransforms(input: string, transforms: SomeTransform[]): string {
for (const someTransform of transforms) {
input = someTransform.transformerWithArg(input);
}
return input;
}
See if it works:
const someTransforms = [
makeSome({
transformer: append,
arg: " END"
}),
makeSome({
transformer: repeat,
arg: 4
}),
];
applySomeTransforms("h", someTransforms);
And if you try to do it inconsistently:
makeSome({transformer: repeat, arg: "oops"}); // error
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.