Search code examples
javascripttypescriptgenerics

How to type return based on generic optional parameters object in TypeScript?


I want to create a function whose options parameter is used to determine the argument passed to its returned function once executed. I think this can be clearer showing my own code, where I'm building an object whose keys are the same as the input but in uppercase:

type IOptions<TA, TB, TC> = Partial<{ a: TA, b: TB, c: TC }>;

const createFun = <TA, TB, TC>(options: IOptions<TA, TB, TC>) => {
  type ITempA = TA extends undefined ? never : TA;
  type ITempB = TB extends undefined ? never : TB;
  type ITempC = TC extends undefined ? never : TC;
  return (fun: (args: { A: ITempA, B: ITempB, C: ITempC }) => void) => {
    fun({
      A: options.a as ITempA,
      B: options.b as ITempB,
      C: options.c as ITempC,
    });
  }
}

createFun({ a: { one: 1, two: 2 } })(args => { console.log(args.A.one) });
              //                       ^
              //                       |
              // Here `args` is as follows
              // { 
              //    A: {one: number; two: number}, 
              //    B: unknown, 
              //    C: unknown 
              // }

What I need is that args does not include any non-provided key. In this example I expect that args = { A: { one: 1, b: 2 } } so TypeScript complains in case I try to do read args.B.

NOTE I've tried to consider all combinations of existing keys inside options, to create an interface based on it, but it is a bad approach as that options object may be larger. I'm showing three items only (A, B, C) for simplification.

Thanks.


Solution

  • The main problem with your function is that the generic type parameters TA, TB, and TC are insufficient to determine which keys were actually present on the argument for the options parameter. If you call createFun({}), TypeScript will have nowhere from which to infer those type arguments, and they will all fall back to unknown, the implicit constraint for type parameters. But if you call createFun({a: x}) where x is of type unknown, the same thing will happen. So you'll need to change the call signature so that the compiler actually has some chance of determining which properties are actually present. There are multiple ways to go about this, but I think the easiest is to give up on TA, TB, and TC, and just make the function generic in O, the type of options itself:

    type IOpts = IOptions<unknown, unknown, unknown>;
    const createFun = <O extends IOpts>(options: O) => { 
    

    From that you could always recover TA, TB, and TC if you need to (e.g., TA is the indexed access type O["a"]).


    Once you do this, then you need to come up with a way to represent the output type, where the returned function's args parameter maps the keys to new keys. That type will most naturally be represented as a mapped type with key remapping, and you'll need some way to translate from your input keys to output keys. Given your description you could just use the Uppercase utility type, but I'll assume you have a fixed set of input and output keys and that they are not necessarily related by something programmatic:

    type KeyMap = { a: "A", b: "B", c: "C" }
    const createFun = <O extends IOpts>(options: O) => {
        return (fun: (args:
            { [K in keyof KeyMap & keyof O as KeyMap[K]]: O[K] }
        ) => void) => {
            fun({
                A: options.a,
                B: options.b,
                C: options.c,
            });
        }
    }
    

    The type of args is { [K in keyof KeyMap & keyof O as KeyMap[K]]: O[K] }. First, K iterates over the keys present in both KeyMap and O. That means if O happens to contain more keys than just a, b, and c, they will be ignored. (Excess properties are sometimes considered compiler errors, but not always.) And any key that is present in KeyMap but not in O will also be ignored. Then the key K is remapped to KeyMap[K], meaning "a" becomes "A", and so on. The property value type is just O[K], so we haven't changed the property value type present at the translated key.


    Let's try it out:

    createFun({ a: { one: 1, two: 2 } })(
        args => {
            args
            //^? args: { A: { one: number; two: number; };}
            console.log(args.A.one);
            args.B; // error 
        }
    );
    

    Looks good. Your example now works as expected. Let's try some more:

    createFun({ a: "abc", b: 123 })(args => {
        args.A.toUpperCase();
        args.B.toFixed();
        args.C; // error
    })
    

    Also reasonable. One more to show how excess properties can sneak in there, and how the output type is still as expected:

    const x = { a: 0, b: 0, c: 0, d: 0 }
    createFun(x)(args => {
        args.A.toFixed();
        args.B.toFixed();
        args.C.toFixed();
        args.D // error
    })
    

    That's also good; the type of args is inferred to have the keys A, B, and/or C depending on whether or not the corresponding keys a, b, and/or c are present in the input.

    Playground link to code