I have a set of indicators represented as an object:
type Indicators = {
UNIT_PRICE: number;
QUANTITY: number;
CURRENCY: "$" | "€";
TOTAL: string;
};
I want to describe a computation to be made on this set of indicators:
type Computation<R extends { [key: string]: any }> = {
output: keyof R;
inputs: Array<keyof R>;
// I Would like the arguments and the returned value to be type checked
compute: (...inputs: any[]) => any;
};
I did not manage to express the relation between inputs
(the indicator names) and the arguments of the compute
function. And the same for output
and the return type of compute
. I guess at some point I have to used mapped types, but I did not find out how.
What I would like is to be able to write that kind of code, with typescript complaining if compute
s signature does not match the types inferred from inputs
and output
.
const computation: Computation<Indicators> = {
output: "TOTAL",
inputs: ["UNIT_PRICE", "QUANTITY", "CURRENCY"],
compute: (unitPrice, quantity, currency) =>
(unitPrice * quantity).toFixed(2) + currency.toUpperCase(),
};
In order for this to work you need Computation
to be generic not just in the object type, but also in the tuple of input keys and the output key. (Well, technically you could try to reprepresent "every possible tuple of input keys and output key" as a big union, but this is annoying to represent and does not scale well, so I'm ignoring this possibility.) For example:
type Computation<T extends Record<keyof T, any>,
IK extends (keyof T)[], OK extends keyof T> = {
output: OK;
inputs: readonly [...IK];
compute: (...inputs: {
[I in keyof IK]: T[Extract<IK[I], keyof T>]
}) => T[OK];
};
The object type is T
(I changed it from R
), the input keys are IK
and the output key is OK
. The compute()
method's input parameter list maps the tuple of keys to a tuple of value types.
Now, though, in order to annotate the type of any value, it will be verbose and redundant:
const validComputation: Computation<
Indicators,
["UNIT_PRICE", "QUANTITY", "CURRENCY"],
"TOTAL"
> = {
output: "TOTAL",
inputs: ["UNIT_PRICE", "QUANTITY", "CURRENCY"],
compute: (unitPrice, quantity, currency) =>
(unitPrice * quantity).toFixed(2) + currency.toUpperCase(),
};
Ideally you'd like the compiler to infer IK
and OK
for you, while letting you specify T
. But currently TypeScript does not allow you to partially specify a type; you either have to specify the whole thing, as above, or let the compiler infer the whole type for you. You could make a helper function to get the compiler to infer IK
and OK
for you, but again, any particular call will only infer all of T
, IK
, and OK
, or none of them. This whole "partial inference" thing is an open issue in TypeScript; see microsoft/TypeScript#26242 for discussion.
The best we can do with the current language is to write something like a curried generic function, where you specify T
on the initial function and then let the compiler infer IK
and OK
on calls to the returned function:
const asComputation = <T,>() =>
<IK extends (keyof T)[], OK extends keyof T>(c: Computation<T, IK, OK>) => c;
And it works like this:
const asIndicatorsComputation = asComputation<Indicators>();
const validComputation = asIndicatorsComputation({
output: "TOTAL",
inputs: ["UNIT_PRICE", "QUANTITY", "CURRENCY"],
compute: (unitPrice, quantity, currency) =>
(unitPrice * quantity).toFixed(2) + currency.toUpperCase(),
});
const wrongComputation = asIndicatorsComputation({
output: "TOTAL",
inputs: ["UNIT_PRICE"],
compute: (unitPrice) => unitPrice.toUpperCase() // error!
});
You call asComputation<Indicators>()
to get a new helper function which accepts a Computation<Indicators, IK, OK>
value for some IK
and OK
inferred by the compiler. You can see that you get the desired behavior where the compute()
method parameters are contextually typed and you'll get an error if you do something wrong.