CanIHazCookieNow
CanIHazCookieNow

Reputation: 152

Infer correct object from nested type in Typescript

I'm trying to make Typescript infer the correct object in a switch case and I'm struggling to understand why my scenario does not work (with an object) but does work as expected when it's a union type.

From the playground link:

interface Base {
  id: number;
  name:  "a" | "b" | "c" | "d"; // This is type-guarding correctly
  type: {
    id: number;
    name: "a" | "b" | "c" | "d"; // This is NOT type-guarding correctly
  }
}

interface A extends Base {
  name: "a";
  type: {
    id: number;
    name: "a";
  }
  a: {
    b: number;
  }
}

interface B extends Base {
  name: "b";
  type: {
    id: number;
    name: "b";
  }
  b: {
    a: number;
  }
}

interface Generic extends Base {
  name: "c" | "d";
  type: {
    id: number;
    name: "c" | "d";
  }
}

type C = A | B | Generic

function test(t: C) {
  // If you switch the switch case to 't.name'
  switch(t.type.name) {
  // switch(t.name) {
    case "a":
      // This will be properly type-guarded as it will infer A
      console.log(t.a)
      break;
    case "c":
      console.log(t.name);
      break;
    case "b":
      // This will be properly type-guarded as it will infer B
      console.log(t.b);
      break;
  }
}

I have one object that can have different properties depending of the value of type.name. I want to, in a switch case, to check for that type.name and have typescript understanding which properties I have access based on that. As per the example, if I do the switch case on name, it works as expected.

Playground link

Besides using as or flattening the object, what other options do I have?

Upvotes: 2

Views: 1126

Answers (1)

jcalz
jcalz

Reputation: 327964

TypeScript does not support "nested" discriminated unions where you use a subproperty to discriminate among members. See microsoft/TypeScript#18758. So while C is a discriminated union with name as the discriminant, you can't use type.name as the discriminant. So you pretty much can't use any of the machinery designed to handle discriminated unions, such as using a switch on the discriminant property.

If you want to get the compiler to type guard on subproperties, so that you don't have to use type assertions or change your object structure, then the only option I can imagine is to write a user-defined type guard function that performs the narrowing for you. It could look like this:

function hasNestedTypeName<
  T extends { type: { name: string } },
  K extends string
>(t: T, k: K): t is
  T extends { type: { name: infer N } }
  ? K extends N ? T : never
  : never {
  return t.type.name === k
}

So if you call hasNestedTypeName(t, k) and it returns true, then the compiler will narrow t to be just those union members whose type.name property can accept k as a value. We have to refactor test to use if/else chains instead of switch because of the boolean return type of type guard functions. Like this:

function test(t: C) {
  if (hasNestedTypeName(t, "a")) {
    // function hasNestedTypeName<C, "a">(t: C, k: "a"): t is A
    t // A
    console.log(t.a)
  } else if (hasNestedTypeName(t, "c")) {
    // function hasNestedTypeName<B | Generic, "c">(t: B | Generic, k: "c"): t is Generic
    t // Generic
    console.log(t.name)
  } else if (hasNestedTypeName(t, "b")) {
    t // B 
    console.log(t.b)
  }

This works the same way as your version, more or less (it's actually hard to get the hasNestedTypeName(t, "c") to work as desired, since if it returns false, the compiler assumes that t is not a Generic, but that's not necessarily the case. I'd stay away from discriminated unions where the discriminant is itself a union, since it could do weird things).

Whether or not it's worth such a change depends on the use case. Personally I'd suggest that refactoring the object shape so that the discriminant is at the top level is the best way to go, since it uses the language's strengths instead of trying to work around its weaknesses.

Playground link to code

Upvotes: 3

Related Questions