Search code examples
typescriptmapped-types

Infer function signature from an object and a list of keys


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 computes 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(),
};

Playground


Solution

  • 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.

    Playground link to code