Home > Software design >  Object with multiple fields of the same generic union type
Object with multiple fields of the same generic union type

Time:12-08

I am looking for a way to discriminate a union type as a generic parameter and have the same type on multiple fields of an object

What I mean with that:

type A = {
  type: 'A',
  someA: string
}

type B = {
  type: 'B',
  someB: string
}

type Union = A | B

type DatabaseUpdate<T extends Union> =  {
  before: T, // <-- 
  after: T // <-- Those should be the same type, either both A, or both B
}

function test(update: DatabaseUpdate<Union>) {
  if (update.after.type === 'A') {
    console.log(update.before.someA) // Error
  }
}

I tried extracting the value based on the type because I figured that

type Test = Exclude<Union, { type: 'A' }> // this is B

works fine. But it still does not seem to be able to figure it out.

type $ElementType<T extends {
    [P in K & any]: any;
}, K extends keyof T | number> = T[K];

type DatabaseUpdate<
  T extends Union,
  K = $ElementType<T, 'type'>,
  X = Extract<Union, { type: K }>
> =  {
  before: X,
  after: X
}

function test(update: DatabaseUpdate<Union>) {
  if (update.after.type === 'A') {
    console.log(update.before.someA) // Error
  }
}

Ideally I would even be able to have a third field e.g. type

type DatabaseUpdate<
  T extends Union,
  K = $ElementType<T, 'type'>,
  X = Extract<Union, { type: K }>
> =  {
  type: K,
  before: X,
  after: X
}

// So that I could do 

function test(update: DatabaseUpdate<Union>) {
  if (update.type === 'A') {
    console.log(update.before.someA)
    console.log(update.before.someA)
  }

if (update.type === 'B') {
    console.log(update.before.someB)
    console.log(update.before.someB)
  }
}

Typescript Playground

CodePudding user response:

In TypeScript, discriminated unions are union types with a discriminant property; each member of the union must have a property with the same key name, and in at least some member the discriminant property value must be of a literal type or other "singleton" type (a type inhabited by exactly one value) like null or undefined.

If you have a value u of a discriminated union type with a discriminant key of k, then checking the value of the discriminant property u.k will cause the compiler to narrow the apparent type of u.


Now, your first approach won't work directly because TypeScript doesn't currently support "nested" discriminated unions. You can only use singleton types to discriminate a union; you cannot use other discriminated union types to discriminate a union.

A union type like {before: A, after: A} | {before: B, after: B} is therefore not a discriminated union. Neither before nor after are singleton types; they are discriminated unions. So if you check update.after.type, this can only serve to narrow the type of update.after. It does nothing to the apparent type of update itself.

There is a feature request at microsoft/TypeScript#18758 to support nested discriminated unions, but it's not clear when or whether this will be implemented.

There are ways to write a user-defined type guard function which can give a similar result, but it will not be as simple as writing if (update.after.type === "A"); it would end up looking something like like if (nestedDiscriminate(update, ["after", "type"], "A")). I won't go into this because there is another way to proceed here.


If you define DatabaseUpdate<Union> so that the resulting type is a union with a discriminant property, then it will be a discriminated union. So adding a type field is a good approach.

Unfortunately, you ran into trouble, because your DatabaseUpdate<Union> evaluates to this:

type DUU = DatabaseUpdate<Union>;
/* type DUU = {
     type: "A" | "B";
     before: A | B;
    after: A | B; 
   }
*/

which is not a union at all, and therefore not a discriminated union. Each property is a union, but an object-of-unions is not the same as a union-of-objects. You can see that the difference, because your type allows assignments like this:

const oops: DUU = {
  type: "A",
  before: { type: "A", someA: "okay" },
  after: { type: "B", someB: "uh oh" }
}

That's valid; the type property is "A", which is a valid "A" | "B", and the after property is a B, which is a valid A | B. Each property of this object type is an independent union type; knowing the value of the type property implies nothing about the before or after properties.


We want DatabaseUpdate<Union> to itself be a union. One way to do this is to make DatabaseUpdate<T> a distributive conditional type, which means DatabaseUpdate<A | B> will evaluate as DatabaseUpdate<A> | DatabaseUpdate<B> (we are distributing the DatabaseUpdate<T> type across unions in T):

type DatabaseUpdate<T> = T extends Union ? { 
  type: T['type']
  before: T,
  after: T
} : never;

The T extends Union ? ... : never construction causes T to be split up into its union constituents before evaluating the type, and then the output is unioned back together.

(I also use simplified DatabaseUpdate to use a direct indexed access type T['type'] instead of the ultimately equivalent $ElementType<T, 'type'>).

Now let's evaluate DatabaseUpdate<Union>:

type DUU = DatabaseUpdate<Union>
/* type DUU = {
     type: "A";
     before: A;
     after: A;
   } | {
     type: "B";
     before: B;
     after: B;
   } */

Now this is a union type, and type is a discriminant property. Suddenly everything works:

function test(update: DatabaseUpdate<Union>) {
  if (update.type === 'A') {
    console.log(update.before.someA) // okay
    console.log(update.after.someA) // okay
  } else {
    console.log(update.before.someB) // okay
    console.log(update.after.someB) // okay
  }
}

Playground link to code

  • Related