How to narrow TypeScript discriminated unions based on partially known discriminators?

64 views Asked by At

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

1

There are 1 answers

0
jcalz On

Your useCustomHook() function is implemented as a single block of code that depends on an args parameter of the union type CustomHookArgs. But the compiler can only analyze that block of code once. It doesn't "distribute" its analysis across the union members of CustomHookArgs. 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:

const useCustomHook = (args: CustomHookArgs) => {
  const { isFeatureOneEnabled, isFeatureTwoEnabled } = args;
  if (isFeatureOneEnabled && isFeatureTwoEnabled) {
    return {
      ...args,
      someSharedThings: "here",
      someFeatureOneThings: "here",
      someFeatureTwoThings: "here",
    }
  }
  if (isFeatureOneEnabled && !isFeatureTwoEnabled) {
    return {
      ...args,
      someSharedThings: "here",
      someFeatureOneThings: "here",
    }
  }

  if (!isFeatureOneEnabled && isFeatureTwoEnabled) {
    return {
      ...args,
      someSharedThings: "here",
      someFeatureTwoThings: "here"
    }
  }

  return {
    ...args,
    someSharedThings: "here",
  }
}

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:

type CustomHookArgs<F1 extends boolean, F2 extends boolean> =
  { isFeatureOneEnabled?: F1, isFeatureTwoEnabled?: F2, someSharedArgsHere: string } &
  { true: { someRequiredFeatureOneArg: string }, false: {} }[`${F1}`] &
  { true: { someRequiredFeatureTwoArg: string }, false: {} }[`${F2}`];

type CustomHookReturn<F1 extends boolean, F2 extends boolean> =
  { someSharedThings: string } &
  {
    true: { isFeatureOneEnabled: true, someFeatureOneThings: string },
    false: { isFeatureOneEnabled?: false }
  }[`${F1}`] &
  {
    true: { isFeatureTwoEnabled: true, someFeatureTwoThings: string },
    false: { isFeatureTwoEnabled?: false }
  }[`${F2}`];


const useCustomHook = <F1 extends boolean = false, F2 extends boolean = false>(
  args: CustomHookArgs<F1, F2>) => {
  const { isFeatureOneEnabled, isFeatureTwoEnabled } = args;
  return {
    ...args,
    someSharedThings: "here",
    ...(isFeatureOneEnabled && { someFeatureOneThings: "here" }),
    ...(isFeatureTwoEnabled && { someFeatureTwoThings: "here" }),
  } as CustomHookReturn<boolean, boolean> as CustomHookReturn<F1, F2>;
};

Essentially the function is generic in the boolean types of the isFeatureOneEnabled and isFeatureTwoEnabled properties, F1, and F2 respectively. If you leave out the properties (and the compiler fails to infer the generic from it) then they default to false. The input is of type CustomHookArgs<F1, F2> and the output is of type CustomHookReturn<F1, F2>. Both the input and output essentially switch on F1 and F2 by using indexed access types that use the stringified template literal type of F1 and F2 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}`] with F extends true ? X : Y.

Anyway let's just make sure it works as desired from the call side:

const obj1 = useCustomHook({
  someSharedArgsHere: "hello"
}); // okay
// const obj1: CHR<false, false>
obj1.someFeatureOneThings; // error
// Property 'someFeatureOneThings' does not exist on type 'CustomHookReturn<false, false>'

const obj2 = useCustomHook({
  someSharedArgsHere: "hello",
  isFeatureOneEnabled: true
}); // error!
// Property 'someRequiredFeatureOneArg' is missing.

const obj3 = useCustomHook({
  someSharedArgsHere: "hello",
  isFeatureOneEnabled: true,
  someRequiredFeatureOneArg: "here"
}); // okay
// const obj3: CHR<true, false>
obj3.someFeatureOneThings; // string

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