skip to Main Content

I am trying to understand how I can enforce discriminated unions. I have a type definition that technically typescript will accept, but is not quite working as intended for me.

NOTE: I missed mentioning earlier that I need these type definitions for a functional component.

There is an official way to define a discriminated union. However, in an IDE, it will throw the following error:
Property 'startDate' does not exist on type 'MyComponentProps'.ts(2339)

Example that is accepted by typescript, but reflects an error in the IDE

import { FC } from "react";

interface BaseProps {
  name: string;
  age: number;
}

type IsEligibleProps = {
  eligible: true;
  startDate: string;
};

type NotEligibleProps = {
  eligible: false;
  reason: string;
};

type MyComponentProps = BaseProps & (IsEligibleProps | NotEligibleProps);

const MyComponent: FC<MyComponentProps> = ({
  name,
  age,
  eligible = false,
  startDate,
  reason
}) => {
  return (
    <div>
      <p>Name: {name}</p>
      <p>Age: {age}</p>
      <p>Eligible: {eligible ? "true" : "false"}</p>
      <p>Response: {eligible ? startDate : reason}</p>
    </div>
  );
};

export default MyComponent;

I have managed to find an inefficient solution that requires me to re-declare all the props for all the possible variants, but set the ones I do not need to null/never.

Example that is accepted by typescript, and does not throw any errors

import { FC } from "react";

interface BaseProps {
  name: string;
  age: number;
}

type EligibleProps =
  | {
      eligible: true;
      startDate: string;
      reason?: never;
    }
  | {
      eligible?: false;
      reason: string;
      startDate?: never;
    };

type MyComponentProps = BaseProps & EligibleProps;

const MyComponent: FC<MyComponentProps> = ({
  name,
  age,
  eligible = false,
  startDate,
  reason
}) => {
  return (
    <div>
      <p>Name: {name}</p>
      <p>Age: {age}</p>
      <p>Eligible: {eligible ? "true" : "false"}</p>
      <p>Response: {eligible ? startDate : reason}</p>
    </div>
  );
};

export default MyComponent;

My question is – is there a way to use the original discriminated unions definition and have the Functional Component also read it, and determine the appropriate corresponding props on a de-structured props list?

Link to erroneous code

Link to working but inefficient code

2

Answers


  1. I think your first code achieved the task.

    interface BaseProps {
    name: string;
    age: number;
    }

    type IsEligibleProps = {
      isEligible: true;
      startDate: string;
    };
    
    type NotEligibleProps = {
      isEligible: false;
      reason: string;
    };
    
    export type EligibleProps = BaseProps & (IsEligibleProps | NotEligibleProps);
    
    
    const myArr: EligibleProps[] =  [{
            name:"Spongebob",
            age:42,
            isEligible: true,
            startDate:"1/1/21"
        },{
            name:"Spongebob",
            age:42,
            isEligible:false,
            reason:"Test"
        }]
    

    UPDATE:

    I want to offer the most flexible option for solving problems. We really need an interface with all properties. I decided to use its value as a caption and val. Thus, we can create an object with the sorting and header settings we need.

    Interfaces:

    ...
    
    interface Mixed extends BaseProps {
      eligible: boolean;
      startDate: string;
      reason: string;
    }
    
    export type MyComponentMixProps = {
      [Property in keyof Mixed as string]: {
        caption: string,
        val: string
      }
    }
    

    Сomponent:

    import { MyComponentProps, MyComponentMixProps  } from "./interface";
    
    const MyComponent: FC<MyComponentProps> = props => {
    
      const getOrdredProps = () => {
        // set an order and captions
        const order: MyComponentMixProps = {
          name: { caption: 'Name', val: ''},
          age: { caption: 'Age', val: ''},
          eligible: { caption: 'Eligable', val: ''},
          startDate: { caption: 'Start date', val: ''},
          reason:{ caption: 'Reason', val: ''},
        }
        // assign eligible=false (when it isn't passed)
        const eligibleProps = props.eligible 
          ? props 
          : Object.assign({eligible: false}, props)
    
        // set an order and stringify values 
        for(const [k, v] of Object.entries(eligibleProps)){
          order[k].val = v.toString()
        }
        // filter empty values
        const result = Object.fromEntries(Object.entries(order)
          .filter(([_, {val}]) => val !== ''))
        return  result
      } 
    
      return (
        <div>
          { Object.entries(getOrdredProps())
            .map(([key, {caption, val}]) => (
              <p key={key}>{caption}: {val}</p>
          ))
        }
        </div>
      );
    };
    

    link to codesandbox

    Login or Signup to reply.
  2. Currently you are only allowed to destructure a union type into an object if that object’s properties exist in every member of the union. Normally that’s a reasonable restriction, but when you have a discriminated union type it would lift it. There’s an open feature request for that at microsoft/TypeScript#46318 marked as "Awaiting More Feedback", so if you want to see this happen it wouldn’t hurt to give that issue a 👍 and possibly describe why your use case is compelling. (It probably wouldn’t help much, either, to get a single extra vote… but it wouldn’t hurt.)


    Until and unless that’s implemented, you’ll need to work around it. One workaround is to just give up on destructuring the relevant properties and access them the "normal" way:

    const MyComponent: FC<MyComponentProps> = ({
      name,
      age,
      ...rest
    }) => {
      return (
        <div>
          <p>Name: {name}</p>
          <p>Age: {age}</p>
          <p>Eligible: {rest.eligible ? "true" : "false"}</p>
          <p>Response: {rest.eligible ? rest.startDate : rest.reason}</p>
        </div>
      );
    };
    

    Here rest is just the part of the object corresponding to the union discriminant and its properties. Then instead of referencing eligible, startDate, or reason, you access rest.eligible, rest.startDate, and rest.reason, which works.


    Another workaround is to augment the discriminated union so that each member mentions all the properties from all the other members, and explicitly prohibit the "cross-term" properties by making them optional properties of the impossible never type. That will make sure when eligible is true, then reason should be known to be undefined. For want of a better term I’ll call this "exclusifying" the union.

    Indeed this is the technique you were talking about as being "inefficient". Luckily you are not required to do this manually. Instead you could write a utility type function to automatically exclusify a union. It could look like this:

    type Exclusify<
      T extends object,
      K extends PropertyKey = AllKeys<T>
    > =
      T extends unknown ? (
        { [P in K]?: P extends keyof T ? unknown : never } & T
      ) extends infer U ? { [P in keyof U]: U[P] } : never : never;
    
     type AllKeys<T> = T extends unknown ? keyof T : never;
    

    Essentially it’s a distributive conditional type that splits T into its union members (that’s the T extends unknown ? ⋯ : never part), and for each one, it intersects in an object type with all the keys from any union member, with the type being never for any key not in the current member.

    Note that "all the keys from any union member" needs to be computed before we split T into union members; I do that by introducing K and giving it a default generic type argument of AllKeys<T> which is a separate distributive conditional type.

    Also intersections can look ugly, so I have collapsed them into single object types using a technique discussed in How can I see the full expanded contract of a Typescript type? .

    Let’s test it:

    type MyComponentProps = Exclusify<BaseProps & (IsEligibleProps | NotEligibleProps)>;
    
    /* type MyComponentProps = {
        name: string;
        age: number;
        eligible: true;
        startDate: string;
        reason?: undefined;
    } | {
        name: string;
        age: number;
        eligible?: false | undefined;
        startDate?: undefined;
        reason: string;
    } */
    

    Looks good; that’s the same type as you had (and so you know it works), but now you can add as many union members as you like and it should automatically be maintained.

    Playground link to code

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search