Jojko
Jojko

Reputation: 57

Restricting the discriminated union type to current property only

I'm having a problem achieving proper validation given the types below:

type T1 {
  __typename: "T1";
  action: "ACTION_1" | "ACTION_2";
  user: string;
}

type T2 {
  __typename: "T2";
  action: "ACTION_3" | "ACTION_4" | "ACTION_5";
}

type T3 {
  __typename: "T3";
  action: "ACTION_6";
  note: string;
}

type T = T1 | T2 | T3

Based on the above, I'm trying to achieve a type that would validate object below:

const obj: MappedObj = {
  T1: {
    ACTION_1: "some string",
    ACTION_2: "some string"
  },
  T2: {
    ACTION_3: "some string",
    ACTION_4: "some string",
    ACTION_5: "some string",
  },
  T3: {
    ACTION_6: "some string"
  }
}

Basically, I tried to create an indexed type, iterating through __typename of T, and then in that property to have another iteration of action. Ideally, I would like the action to be based on the "current" __typename but my current solution does not distinguish from different variants of T and just requires ALL possible actions to be specified in a object:

type MappedObj = { [key in T["__typename"]: { key2 in T["action"]: string } }

For each key in T["typename"] I get an error in this fashion:

Type '{ ACTION_1: string; ACTION_2: string; }' is missing the following properties from type '{ ACTION_3: string; ACTION_4: string; ACTION_5: string; ACTION_6: string; ts(2739)

Is there a way to tie action to __typename of "current" property?

Upvotes: 2

Views: 894

Answers (1)

jcalz
jcalz

Reputation: 328097

With key remapping in mapped types as introduced in TypeScript 4.1, you are allowed to iterate over arbitrary union types, not just key-like types, as long as you re-map the members of that union to keys. That means your MappedObj could be defined like this:

type MappedObj = {
    [U in T as U["__typename"]]: {
        [P in U["action"]]: string
    }
};

We are iterating over the union T. For each member U of that union, we make a property whose key is U["__typename"] and whose value is the equivalent of Record<U["action"], string>. That produces the type you're looking for:

/* type MappedObj = {
    T1: {
        ACTION_1: string;
        ACTION_2: string;
    };
    T2: {
        ACTION_3: string;
        ACTION_4: string;
        ACTION_5: string;
    };
    T3: {
        ACTION_6: string;
    };
} */

Note that this is also possible without key remapping (and therefore in TypeScript versions earlier than 4.1), but because you're iterating over the union T["__typename"], for each such key K you need to extract from T the member which is assignable to {__typename: K}. So it could be written like this:

type MappedObj = {
    [K in T["__typename"]]: {
        [P in Extract<T, { __typename: K }>["action"]]: string
    }
};

using the Extract<X, Y> utility type which evaluates to the member(s) of the union X which are assignable to Y. That's a bit more work than before: remapping can just iterate U in T, and without it you have to synthesize U from K in T["__typename"]. I usually suggest key remapping nowadays, but it can still be useful to know how to do it a different way.

Playground link to code

Upvotes: 1

Related Questions