Home > Net >  How to force two discriminated unions to have the same type for their discriminator property?
How to force two discriminated unions to have the same type for their discriminator property?

Time:01-30

My use case is the following:

type Discriminator = "one" | "two";

type OneTypeA = {
  type: "one",
  one: 1
}

type TwoTypeA = {
  type: "two",
  two: 2,
}

type TypeA = OneTypeA | TwoTypeA;

type OneTypeB = {
  type: "one",
  one: "unu"
}

type TwoTypeB = {
  type: "two",
  two: "du",
}

type TypeB = OneTypeB | TwoTypeB;

In my example, TypeA and TypeB are discriminated by the same type, however, this is not enforced (Discriminator is not used anywhere), thus it'd be possible for anyone to come and add any type to these unions that doesn't match the Discriminator. Is there any way I could enforce this?

My closest solution would be to have something like

type DiscriminatorRequired<T> = T extends { type: Discriminator } ? T : never;
type TypeA = DiscriminatorRequired<OneTypeA | TwoTypeA>
type TypeB = DiscriminatorRequired<OneTypeB | TwoTypeB>

However, this only filters out the types that don't match which is confusing and suboptimal. I'd prefer that this wasn't able to compile with an error indicating that the type must have a type property of type Discriminator

CodePudding user response:

The accepted answer is a nice, simple constraint on the type property, but you could also fully constrain the types and avoid hardcoding every type using mapped types.

TS Playground

Utility
type TypeDiscriminatedSinglePropertyRecord<Discriminator extends string, Map extends Record<string, any>> = {
    [Key in Discriminator]: {type: Key} & Record<Key, Map[Key]>
}[Discriminator];
Discriminator
type Discriminator = "one" | "two";
A Types
type AType = TypeDiscriminatedSinglePropertyRecord<Discriminator, {
    "one": 1,
    "two": 2,
    "three": 3 // Filtered out by 'TypeDiscriminatedSinglePropertyRecord'
}>;
//  type AType = ({
//    type: "one";
//  } & Record<"one", 1>) |
//  ({
//    type: "two";
//  } & Record<"two", 2>)

const oneAType: AType = {
    type: 'one',
    one: 1
};
const twoAType: AType = {
    type: 'two',
    two: 2
};

const threeAType: AType = {
    type: 'three',
//  ^^^^
//  Type '"three"' is not assignable to type '"one" | "two"'.
    three: 3
};
const allAType: AType = {
    type: 'one',
    one: 1,
    two: 2
//  ^^^^^^
//  Type '{ type: "one"; one: 1; two: number; }' is not assignable to type 'AType'.
//    Object literal may only specify known properties, and 'two' does not exist in type '{ type: "one"; } & Record<"one", 1>'.
};
B Types
type BType = TypeDiscriminatedSinglePropertyRecord<Discriminator, {
    "one": "un",
    "two": "du"
}>;
//  type BType = ({
//    type: "one";
//  } & Record<"one", "un">) |
//  ({
//    type: "two";
//  } & Record<"two", "du">)

const oneBType: BType = {
    type: 'one',
    two: 'du'
//  ^^^^^^^^^
//  Type '{ type: "one"; two: string; }' is not assignable to type 'BType'.
//    Object literal may only specify known properties, and 'two' does not exist in type '{ type: "one"; } & Record<"one", "un">'.
};
const twoBType: BType = {
    type: 'two',
    two: 'du'
};

const threeBType: BType = {
    type: 'two',
    three: 'trois'
//  ^^^^^^^^^^^^^^
//  Type '{ type: "two"; three: string; }' is not assignable to type 'BType'.
//    Object literal may only specify known properties, and 'three' does not exist in type '{ type: "two"; } & Record<"two", "du">'.
};

CodePudding user response:

Just constrain the generic itself:

TS Playground

type Discriminator = "one" | "two";

type OneTypeA = {
  type: "one";
  one: 1;
};

type TwoTypeA = {
  type: "two";
  two: 2;
};

type ThreeTypeA = {
  type: "three";
  three: 3;
};

type Constrained<T extends { type: Discriminator }> = T;

type TypeAOk = Constrained<OneTypeA | TwoTypeA>; // ok
type TypeANotOk = Constrained<OneTypeA | TwoTypeA | ThreeTypeA>; /*
                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Type 'OneTypeA | TwoTypeA | ThreeTypeA' does not satisfy the constraint '{ type: Discriminator; }'.
  Type 'ThreeTypeA' is not assignable to type '{ type: Discriminator; }'.
    Types of property 'type' are incompatible.
      Type '"three"' is not assignable to type 'Discriminator'.(2344) */

  •  Tags:  
  • Related