Search code examples
typescripttypescript-typingstypescript-generics

Typescript Argument of type {...} is not assignable to parameter of type 'never'


Trying to create a function where (from a typing perspective) based on the first two arguments it determines whether a 3rd argument should be provided or not. Whether the 3rd argument is required is based on if a specific nested key exists in the object based on the path of the first two arguments. I created a contrived example to demonstrate:

Example

type ExampleType = {
  john: {
    toolbox: { color: string }
  },
  jane: {
    screwdriver: { color: string }
  },
  sally: {
    hammer: { color: string },
    nail: {}
  }
}

type ColorArgs<Color> = Color extends never ? { color?: Color } : { color: Color };

type ExampleFn = <
  Name extends Extract<keyof ExampleType, string>,
  Thing extends Extract<keyof ExampleType[Name], string>,
  Color extends (ExampleType[Name][Thing] extends { color: infer C } ? C : never)
>(config: { name: Name, thing: Thing } & ColorArgs<Color>) => string;

export const exampleFn: ExampleFn = ({ name, thing, color }) => {
  console.log(name, thing, color);
  return 'hello';
};

exampleFn({
  name: 'sally',
  thing: 'nail',
});

I'm expecting the function to work correctly and not allow a color argument when name is sally and thing is nail.


Solution

  • Firstly, there's no real reason for this example that you'd need to have three generic type parameters in your function. Instead of using Color as a type parameter constrained to some type, you can just use that type instead:

    type ExampleFn = <
      K1 extends keyof ExampleType,
      K2 extends keyof ExampleType[K1]      
    >(config: { name: K1, thing: K2 } & ColorArgs<(
        ExampleType[K1][K2] extends { color: infer C } ? C : never
     )>) => string;
    

    Then we need to fix the complicated ColorArgs<( ExampleType[K1][K2] extends { color: infer C } ? C : never )> to be something that actually works. Your ColorArgs type function is a distributive conditional type, and those always map never to never in order to be consistent with unions (see the answer to Inferring nested value types with consideration for intermediate optional keys for more information). Since you're apparently trying to use never as a sigil to detect when color is not a key of the input, then you're getting never out instead of your intended type.

    So the best way forward here is to completely avoid the intermediate never, and just handle both cases separately. I'd do this:

    type ExampleFn = <
      K1 extends keyof ExampleType,
      K2 extends keyof ExampleType[K1],
    >(config:
      { name: K1, thing: K2, color?: unknown } &
      ColorArgs<ExampleType[K1][K2]>
    ) => string;
    
    type ColorArgs<T> =
      "color" extends keyof T ? Pick<T, "color"> : { color?: never }
    

    Here I'm passing in the full ExampleType[K1][K2] for T, and then ColorArgs checks if color is a key of T or not. If it is, you get Pick<T, "color"> to give you the part of T with that key, and if not, you get {color?: never} to prohibit such a property (it technically makes it an optional property of type never, which is somewhat different, but very close to being a prohibition).

    The reason I include color?: unknown in the type of config is just to make sure the compiler knows that it's always safe to index into config with color no matter what generic types K1 and K2 are. Otherwise you'd run into a problem here:

    export const exampleFn: ExampleFn = ({ name, thing, color }) => {
      console.log(name, thing, color);
      return 'hello';
    };
    

    where the destructured color property wouldn't be recognized as valid.

    And now we can check that it works as desired:

    exampleFn({ name: 'sally', thing: 'nail' }); // okay
    exampleFn({ name: 'sally', thing: 'nail', color: "red" }); // error
    exampleFn({ name: "jane", thing: "screwdriver", color: "red" }); // okay
    exampleFn({ name: "jane", thing: "screwdriver" }); // error
    

    Looks good.

    Playground link to code