Search code examples
typescriptgenericsexistential-typeconditional-types

Conditional and Existential Types on Translated Typescript Object


I hope this makes sense.

I'm trying to write a function that takes an object where each key equals a function and returns a function who's rest argument(s) are a translation of that object to [a key of the object, and the params of that key's function]

My Code:

export const useConfig =
  <T extends Record<string, (...a: any) => null>>(features: T) =>
  <O extends T, K extends keyof O>(...args: [K, ...Parameters<O[K]>][]): void =>
    void 0;

const config = useConfig({
  attr: (prop: 'id', val: string) => null,
  style: (prop: 'font', val: 'arial') => null,
});

The Problem:

This const t2 = config(['attr', 'font','']); shouldn't work, it should:

  1. complain that "font" is wrong.
  2. After having defined "attr" as the first param, the second param should be typed against "prop:'id'" and the third against "val:string".

R&D

  1. I've been trying my best to figure out if this is an Existential Type issue (which I believe it is) but I can't make the connection
  2. I know this is an issue of Conditional typing because if I create multiple generics for the returned function with a bunch of optional param, each assigned to each generic, it works. However, every time I try to figure out how to do this "dynamically" I seem to land on "Existential Types" and I go round and round.

Any help would be much appreciated. I feel like I'm just not getting something fundamental about the type system.


Solution

  • In what follows I'll call the operation in question, turning a key K (which extends keyof T for some suitable T whose values are all function types) into the tuple [K, ...Parameters<T[K]>], "parameterizing" K, or a "parameterization" of K.

    Conceptually you want the return type of useConfig to be a function which accepts a variadic number of arguments, where each argument is a parameterization of some key in keyof T. You don't really want to know which argument is a parameterization of which key, just that each one corresponds to some key. This use of "some" is indeed a hint that the kind of generic quantification you'd need here is existential instead of the "normal" universal quantification. You can think of normal, universal generics as intersections over every acceptable type, while existential generics are unions over every acceptable type.

    And here, since "every acceptable type" is just the single members of keyof T, then you can represent this union directly. All you want to do is distribute the parameterization operation over the union in K to make a new union.

    If you want to distribute an operation over keylike types, you can use a distributive object type (as coined in microsoft/TypeScript#47109) where you make a mapped type and then immediately index into it. If you have a key set KS and you want to distribute the operation F<K> over it, you can write that like {[K in KS]: F<K>}[KS]. In your case KS is keyof T and F<K> is [K, ...Parameters<T[K]>]. So you get this:

    const useConfig =
    <T extends Record<string, (...a: any) => null>>(features: T) =>
      (...args: { [K in keyof T]-?: [K, ...Parameters<T[K]>] }[keyof T][]): void =>
        void 0;
    

    Let's see what happens when we call it:

    const config = useConfig({
      attr: (prop: 'id', val: string) => null,
      style: (prop: 'font', val: 'arial') => null,
    });
    
    /* const config: (...args: (
      ["attr", "id", string] | ["style", "font", "arial"]
    )[]) => void */
    

    So this is exactly what you want. Each argument passed into config() should be a tuple of type ["attr", "id", string] or one of type ["style", "font", "arial"]. And you'll get the type checking you care about:

    const t = config(
      ["attr", "id", "abc"], // okay
      ["style", 'font', "arial"], // okay
      ["attr", "font", ""] // error!
      //~~~~~~~~~~~~~~~~~~
    );
    

    Unfortunately that doesn't give you a great experience with IntelliSense and autocompletion. This isn't really a problem with the above solution, but a limitation or missing feature of TypeScript, which is requested at microsoft/TypeScript#38603.

    We can work around this by having the compiler actually try to figure out which argument is a parameterization of which key. That means if we call

    const t = config(
      ["attr", "id", "abc"], 
      ["style", 'font', "arial"], 
      ["attr", "font", "abc"]
    );
    

    the compiler needs to know "the first key is "attr", the second one is "style", and third one is "attr". If you think of this as a tuple of keys KS, then here KS is ["attr", "style", "attr"]. And we need to represent the operation of turning the KS tuple into a tuple of parameterizations of each key in the tuple. That is, we want to map the parameterization operation over the input tuple to get an output tuple.

    That version looks like this:

    const useConfig =
    <T extends Record<string, (...a: any) => null>>(features: T) =>
      <KS extends Array<keyof T>>(...args: {
        [I in keyof KS]: [KS[I], ...Parameters<T[Extract<KS[I], keyof T>]>]
      }): void =>
        void 0;
    

    It's a bit more involved; the compiler does map tuples to tuples with {[I in keyof KS]: F<I>} acting only on the numeric-like indices I, but it doesn't quite know that it's doing this inside the body of the mapped type, so it will balk if you treat KS[I] as if it's keyof T, (because "wHaT iF I iS somE arrAy meTHod nAme LIke "push"? see ms/TS#27995). We need to use the Extract<T, U> utility type to convince the compiler that KS[I] can be treated as if it's assignable to keyof T.

    Now when we call config():

    const t = config(
      ["attr", "id", "abc"], // okay
      ["style", 'font', "arial"], // okay
      ["attr", "font", "abc"], error!
      // -----> ~~~~ <---- error here
    );
    
    /* const config: <["attr", "style", "attr"]>(
        args_0: ["attr", "id", string], 
        args_1: ["style", "font", "arial"], 
        args_2: ["attr", "id", string]) => void 
    */
    

    You can see that the compiler infers ["attr", "style", "attr"] for KS, and then is unhappy specifically about the invalid "font" value in the args_2 argument.

    Playground link to code