I'm working on a React hook for abstracting state for various features that share some function arguments, but where certain feature-specific arguments should be required/disallowed based on which features are enabled. I have the hook function accepting one argument which is an object with a discriminated union type based on boolean feature flag properties, and it returns an object which conditionally includes some properties based on the enabled features:
type CustomHookArgs = (
| ({ isFeatureOneEnabled: true } & FeatureOneArgs)
| { isFeatureOneEnabled?: false }
) &
(
| ({ isFeatureTwoEnabled: true } & FeatureTwoArgs)
| { isFeatureTwoEnabled?: false }
) & {
someSharedArgsHere: string;
};
const useCustomHook = (args: CustomHookArgs) => {
const { isFeatureOneEnabled, isFeatureTwoEnabled } = args;
// Do some stuff
return {
...args,
someSharedThings: "here",
...(isFeatureOneEnabled && { someFeatureOneThings: "here" }),
...(isFeatureTwoEnabled && { someFeatureTwoThings: "here" }),
};
};
If I call useCustomHook
and start typing the properties of the CustomHookArgs
object, VS Code IntelliSense will allow/autocomplete the properties of the generic (not yet narrowed) union:
{ isFeatureOneEnabled?: boolean; isFeatureTwoEnabled?: boolean; someSharedArgsHere: string }
The properties in FeatureOneArgs
and FeatureTwoArgs
are considered not part of the CustomHookArgs
type in this scope and should cause a type error if I include them (which is only working sometimes, but that's a question for another time). If I omit the feature booleans or pass them as false
, the object returned by useCustomHook
is correctly inferred to not include the properties someFeatureOneThings
or someFeatureTwoThings
(or as I discover now, it infers them as string | undefined
in the TS playground. That's not what I see in my environment for some reason).
If I call useCustomHook
with featureOneEnabled: true
, the args object narrows correctly and TS requires me to pass the required properties from FeatureOneArgs
, but still not the properties from FeatureTwoArgs
. Great, so far so good.
// No errors, as expected
useCustomHook({
someSharedArgsHere: "hello"
});
// Error, as expected (someRequiredFeatureOneArg is required)
useCustomHook({
someSharedArgsHere: "hello"
isFeatureOneEnabled: true,
});
// No errors, as expected
useCustomHook({
someSharedArgsHere: "hello",
isFeatureOneEnabled: true,
someRequiredFeatureOneArg: "here",
});
If I try to take the object returned by the hook and reference properties on it, I would expect to be able to access someFeatureOneThings
if I passed isFeatureOneEnabled: true
to the hook. But here's my problem: The return value of the hook is not inferring its type correctly from the narrowed type of CustomHookArgs
based on values passed into useCustomHook
. someFeatureOneThings
still doesn't exist on that object's type (in my env... in the TS playground it's still string | undefined
). I would expect it to be there with type string
.
const hookReturnObj = useCustomHook({
someSharedArgsHere: "hello",
isFeatureOneEnabled: true,
someRequiredFeatureOneArg: "hey!",
});
// No problem here
console.log(hookReturnObj.someSharedThings);
// Error: Property 'someFeatureOneThings' does not exist on type...
console.log(hookReturnObj.someFeatureOneThings);
If I add a conditional around my reference to the feature-specific property, it narrows correctly in that block:
if (hookReturnObj.isFeatureOneEnabled) {
// No more error here
console.log(hookReturnObj.someFeatureOneThings);
}
(Okay, here's another difference in the TS playground -- it doesn't narrow at all there, someFeatureOneThings
is still string | undefined
).
But I already told TS isFeatureOneEnabled
was true when I called useCustomHook
. Why do I need to narrow it again?
Am I misunderstanding the expected behavior of a discriminated union here? I would expect that leaving the return type of useCustomHook
to be inferred from its return
statement would cause the someFeatureOneThings
property to exist on that type without any conditional if I passed isFeatureOneEnabled: true
.
As a workaround, is there perhaps a way I can explicitly narrow the type with some custom utility type? Something like this (not real code):
type ReturnObjUnion = ReturnType<typeof useCustomHook>;
type ReturnObjWithFeatureOneEnabled = ReturnType<typeof useCustomHook({ isFeatureOneEnabled: true })>;
console.log((hookReturnObj as ReturnObjWithFeatureOneEnabled).someFeatureOneThings);
That's not much better than having to add a condition though.
One thing I tried was to add an explicit return type and use satisfies
(didn't work):
type CustomHookReturnObj = {
someSharedThings: string;
} & (
| ({ isFeatureOneEnabled: true } & { someFeatureOneThings: string })
| { isFeatureOneEnabled?: false }
) &
(
| ({ isFeatureTwoEnabled: true } & { someFeatureTwoThings: string })
| { isFeatureTwoEnabled?: false }
);
const useCustomHook = (args: CustomHookArgs) => {
const { isFeatureOneEnabled, isFeatureTwoEnabled } = args;
// Do some stuff
return {
...args,
someSharedThings: "here",
(...(isFeatureOneEnabled && { someFeatureOneThings: "here" })),
(...(isFeatureTwoEnabled && { someFeatureTwoThings: "here" })),
} satisfies CustomHookReturnObj;
};
Any help would be greatly appreciated. Here's a TS playground link where I've been trying to reproduce this, although I'm seeing different results there than in my project (as noted above).
Your
useCustomHook()
function is implemented as a single block of code that depends on anargs
parameter of the union typeCustomHookArgs
. But the compiler can only analyze that block of code once. It doesn't "distribute" its analysis across the union members ofCustomHookArgs
. So it loses all track of various correlations, and the returned union is wider than you want it to be, including terms that will not actually be possible. You get a kind of indiscriminate mixture of cases. There's no way to even ask for the compiler to analyze a single block this way (see microsoft/TypeScript#25051 for a declined feature request). If you want the compiler to properly account for the different possibilities, you need to actually write one a block of code for each case, like this:That's quite redundant, but at least the output doesn't contain any impossible union members. Conversely, if you want to write a single
return
statement, you'll probably need to use a type assertion or the like to convince the compiler that the value conforms to your desired output type.Furthermore, you are expecting the return type of
useCustomHook()
to depend on how you call it. But that doesn't happen automatically with union inputs. The only ways to get a function to output different types depending on the input types is to either overload it make it generic.And again, the compiler is unlikely to be able to follow the logic of your implementation either way. Overloads will generally allow unsafe implementations without complaint, and generics will generally be prematurely widened to constraints and you'll get errors for safe implementations. Either way you will probably have to take some of the type safety responsibility upon yourself, since either the compiler will allow too much (so you need to be careful) or too little (so you need to use a type assertion, which also requires care).
It's up to you which way you'd like to proceed. One possibility is to make the function generic, come up with a type relationship between input and output types, and just assert that your implementation behaves that way. Maybe like this:
Essentially the function is generic in the
boolean
types of theisFeatureOneEnabled
andisFeatureTwoEnabled
properties,F1
, andF2
respectively. If you leave out the properties (and the compiler fails to infer the generic from it) then they default tofalse
. The input is of typeCustomHookArgs<F1, F2>
and the output is of typeCustomHookReturn<F1, F2>
. Both the input and output essentially switch onF1
andF2
by using indexed access types that use the stringified template literal type ofF1
andF2
as the index. This is a bit of a hack to avoid conditional types. If you want you could replace{true: X, false: Y}[`${F}`]
withF extends true ? X : Y
.Anyway let's just make sure it works as desired from the call side:
Looks good. Again, you might want to use a different approach, but you'll be subject to the same general issues as outlined above.
Playground link to code