3

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

foreverAnIntern
  • 414
  • 6
  • 15
  • 1
    *"However, typescript will not allow this as the proptypes are conflicting - given that reason is missing on IsEligibleProps and startDate is missing on NotEligibleProps"* What was the specific usage giving you an error, and what was the error? It's [just fine](https://tsplay.dev/mqYORm) in the general case. – T.J. Crowder May 04 '23 at 16:16
  • If you check the codesandbox I linked (under the `WrongComponent.tsx` file), if you pass it into the component, the component throws an error saying `Property 'eligible' does not exist on type 'EligibleProps'.ts(2339)` @tj – foreverAnIntern May 04 '23 at 16:21
  • You're right @jcalz - it should be discriminated unions, not conditional typings. Updated it – foreverAnIntern May 04 '23 at 16:27
  • 1
    @foreverAnIntern - It's important that all of the code related to your question be **in** your question, not just linked. Three reasons: People shouldn't have to go off-site to help you; some sites are blocked for some users; and links rot, making the question and its answers useless to people in the future. Please put a [mcve] **in** the question. More: [*How do I ask a good question?*](/help/how-to-ask) and [*Something in my web site or project doesn't work. Can I just paste a link to it?*](https://meta.stackoverflow.com/questions/254428/) – T.J. Crowder May 04 '23 at 17:04
  • The [TypeScript playground]() is great for creating an all-in-one [mre]. (And has the added bonus that you can, in **addition** to putting all of the code from the playground in the question, also paste a link to it so people don't have to copy-and-paste. But the link is very secondary.) – T.J. Crowder May 04 '23 at 17:06
  • @tj & @jcalz did my best to update the question to reflect the minimum reproducible example as per the guidelines. Also, when I said `typescript will not allow this` I actually meant that the IDE will throw an error - this is otherwise accepted by typescript. Sorry, still working through describing my problem properly – foreverAnIntern May 04 '23 at 17:42
  • What you want is a missing feature, requested at [ms/TS#46318](https://github.com/microsoft/TypeScript/issues/46318). So for now you need to work around it; you could do it without destructuring like [this](https://tsplay.dev/wOleMN), or you could write a utility type to apply your "inefficient" solution programmatically like [this](https://tsplay.dev/w1nzlN). Do either of those meet your needs? If so I'll write up an answer explaining; if not, what am I missing? – jcalz May 04 '23 at 17:56
  • 1
    I was just trying out the `...rest` method myself - I managed to get a working version through this [guide](https://thoughtbot.com/blog/the-case-for-discriminated-union-types-with-typescript). But by golly I absolutely love the `Exclusify` util you've made. I will definitely try to fiddle-doodle with the utility, see if I can try and figure it out. Thanks a ton! And if there's an answer, I will accept it. Otherwise I'm happy to close the question. – foreverAnIntern May 04 '23 at 18:03

2 Answers2

3

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
0

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

Daniil Loban
  • 4,165
  • 1
  • 14
  • 20
  • Hey! I agree that typescript accepts this, but when passing the props into a component, if I de-structure it, I get all kinds of errors from vscode. Since its a functional component, I would like it to determine what to render based on whether `eligible` is true or false. In this case, I think that all the props should be accepted, i.e. the IDE should not throw an error for any of the params – foreverAnIntern May 04 '23 at 17:48
  • @foreverAnIntern I'm still for the first option, as far as destructuring is concerned, I'll supplement the answer on this. – Daniil Loban May 04 '23 at 18:15