Home > Back-end >  Refining generic type parameter without typeguard
Refining generic type parameter without typeguard

Time:10-13

I have a type Box<T> where T will always be something that extends a State which is a discriminated union.

type StateA = { type: "A"; a: number };
type StateB = { type: "B"; b: string };
type State = StateA | StateB;

interface Box<T extends State = State> {
  state: T;
}

I would now like to inspect what kind of Box a variable is and narrow the type argument to that more specific type. The naive approach of checking the type of the discriminated union sort of works. The usual type inference using if or switch statements works fine when immediately accessing properties of the boxed state. But the type of the Box doesn't narrow, only the type of Box.state.

const b: Box = { state: { type: "B", b: "str" } };
if (b.state.type === "B") {
  console.log(b.state.b); // Inferred correctly
  const bRefined: Box<StateB> = b; // Assignment not possible
}

This can be worked around with a user defined type guard though:

function isBoxB(b: Box): b is Box<StateB> {
  return b.state.type === "B";
}

if (isBoxB(b)) {
  console.log(b.state.b); // Inferred correctly
  const bRefined: Box<StateB> = b; // Assignment possible
}

I can live with that workaround, but I am not exactly happy with it. Is there any better way to automatically narrow the type of the surrounding Box without writing custom type guards?

The whole code is available on the Typescript Playground.

CodePudding user response:

In TypeScript, narrowing the apparent type of an object's property (or subproperty, or sub-subproperty, etc) does not narrow the apparent type of the object itself, in general. The only time this does happen is if the object is of a discriminated union type and the property you're checking is its discriminant.

In your case, although the state property of Box is of the discriminated union type State, Box itself is not a discriminated union. It's not a union at all. So if you have a value b of type Box, then even though checking b.state.type will narrow b.state, it will not narrow b itself.

This is a known limitation of TypeScript, and has been reported multiple times. The currently-open issue asking for improvement here is microsoft/TypeScript#42384. In the past, these were just closed as being too expensive to fix (in order for a check of a.b.c.d.e to narrow more than just a.b.c.d.e, the compiler might need to synthesize new types to represent the effects on a.b.c.d, a.b.c, a.b, and a). A comment suggests that maybe the situation has evolved and it is possible to implement. But for now it's not implemented.

Until and unless this changes, we just have to work around it.


One workaround which sometimes works for people is to copy the existing object into a new object where the checked property is singled out for copying explicitly. This walks the compiler through the logic of synthesizing the narrowed type. In your case it looks like this:

if (b.state.type === "B") {
  const bRefined: Box<StateB> = { ...b, state: b.state } // okay
}

Here we've spread the existing b object into a new object literal, and then explicitly copied the state property over. Now the compiler sees that {...b, state: b.state} is of type Box<StateB>.


Otherwise, the general workaround is to build a custom type guard function which encapsulates the concept of "a check of a property should narrow the parent". It could look like this:

function hasPropType<T extends object, K extends keyof T, V extends T[K]>(
  obj: T, key: K, valGuard: (x: T[K]) => x is V): obj is T & Record<K, V> {
  return valGuard(obj[key]);
}

This tells the compiler that if valGard(obj[key]) is true, then hasPropType(obj, key, valGuard) can narrow the type of obj. In your example it could look like this:

if (hasPropType(b, "state", (x): x is StateB => x.type === "B")) {
  const bRefined: Box<StateB> = b; // okay
}

Of course for a single usage this is more complicated than just using your isBoxB() method. But if you find yourself doing such checks a lot, you might not need to build individual type guard functions for each check.


A compromise here might be to write a slightly more general type guard than isBoxB but less general than hasPropType:

function isBox<K extends State['type']>(type: K, b: Box
): b is Box<Extract<State, { type: K }>> {
  return b.state.type === type;
}

if (isBox("B", b)) {
  const bRefined: Box<StateB> = b; // okay
}

Playground link to code

  • Related