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