core icon indicating copy to clipboard operation
core copied to clipboard

Conditional properties through discriminated unions and intersections in TypeScript

Open theguriev opened this issue 2 years ago • 10 comments
trafficstars

What problem does this feature solve?

https://github.com/vuejs/core/issues/7553 Reopening this because I believe the problem is not solved. It is still impossible to use coditional props.

Just try this after npm create vue@latest inside HelloWorld component

<script setup lang="ts">
interface CommonProps {
  size?: 'xl' | 'l' | 'm' | 's' | 'xs'
}

type ConditionalProps =
  | {
      color?: 'normal' | 'primary' | 'secondary'
      appearance?: 'normal' | 'outline' | 'text'
    }
  | {
      color: 'white'
      appearance: 'outline'
    }

type Props = CommonProps & ConditionalProps
defineProps<Props>()
</script>

and then try to use it

image

Theoretically it should not allow us to use both. That is, there can't be color="white" appearance="text" only color="white" appearance="outline"

What does the proposed API look like?

{
      color?: 'normal' | 'primary' | 'secondary'
      appearance?: 'normal' | 'outline' | 'text'
    }
  | {
      color: 'white'
      appearance: 'outline'
    }

theguriev avatar Aug 10 '23 09:08 theguriev

Added another simple example with fully "exclusive" props that should be covered as well and does not work (yet).

TheAlexLichter avatar Aug 10 '23 10:08 TheAlexLichter

I ran into this many times, huge +1.

goulashify avatar Sep 13 '23 13:09 goulashify

Typescript allows me to use Conditional Types, but Vue doesn't allow me to do this

DaniilIsupov avatar Sep 27 '23 11:09 DaniilIsupov

C'mon Vue and TS NEED FULL SYNERGY.

louiss0 avatar Nov 03 '23 21:11 louiss0

Hi guys, Any update here? @sxzz if you can please follow up on this issue.

ThejanNim avatar Feb 28 '24 10:02 ThejanNim

This is a type restriction of defineComponent, as a current solution you can use generic. This will bypass defineComponent and define the component as a functional component.

<script setup lang="ts" generic="T">
interface CommonProps {
  size?: 'xl' | 'l' | 'm' | 's' | 'xs'
}
// ...
</script>

johnsoncodehk avatar Apr 25 '24 14:04 johnsoncodehk

This is a type restriction of defineComponent, as a current solution you can use generic. This will bypass defineComponent and define the component as a functional component.

<script setup lang="ts" generic="T">
interface CommonProps {
  size?: 'xl' | 'l' | 'm' | 's' | 'xs'
}
// ...
</script>

Unfortunately vue-test-utils does not support generic components with Typescript

occitaneUbald avatar May 31 '24 17:05 occitaneUbald

This is a type restriction of defineComponent, as a current solution you can use generic. This will bypass defineComponent and define the component as a functional component.

<script setup lang="ts" generic="T">
interface CommonProps {
  size?: 'xl' | 'l' | 'm' | 's' | 'xs'
}
// ...
</script>

@johnsoncodehk while props with a discriminator work now in the playground (e.g. color in the example above), two different props as in my comment are still not possible as far as I see.

TheAlexLichter avatar May 31 '24 20:05 TheAlexLichter

Any updates on the plans for this? I guess with recent PR #10801 this is in the works?

AdrianFahrbach avatar Jul 29 '24 20:07 AdrianFahrbach

@johnsoncodehk while props with a discriminator work now in the playground (e.g. color in the example above), two different props as in my comment are still not possible as far as I see.↳

It's mostly because of how Typescript is designed due to its structural type system and how Typescript does checks with unions. So in your example, if we simplify it for Typescript, it would be smth like this:

type Props = { one: string } | { other: number };

const Component = (props: Props) => {};

Component({ one: '123', other: 1 }) // no errors

So when you pass some object, it will be checked for each union member separately if it satisfies them. The current object satisfies both of them, so there are no errors.

type Props = { one: string } | { other: number };
const obj = {
  one: 'sad',
  other: 1
} satisfies Props; // no error

So it's mostly not a problem of vue or its typings, it's how Typescript works with unions.

The solution here would be to use discriminated unions (have the same property with different values in union type) or you can manually create a type that will exclude other properties by assigning a never type with an optional mark to fields listed in another union member.

type PropsWithNever = { one: string; other?: never } | { one?: never; other: number };

const objNever1 = { one: 'asd' } satisfies PropsWithNever;
const objNever2 = { other: 1 } satisfies PropsWithNever;

const objNever3 = {
  one: 'asd',
  other: 1
} satisfies PropsWithNever; // shows error

To simplify usage of that I usually use these utilities:

type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };

export type XOR<T, U> = T | U extends object
  ? (Without<T, U> & U) | (Without<U, T> & T)
  : T | U;

type PropsWithXOR = XOR<{ one: string }, { other: number }>;

const objWithXOR = {
  one: 'sad',
  other: 1
} satisfies PropsWithXOR; // shows error

You can check and play with example here

I usually use XOR when I have common props and I can make & operation with XOR result of some exclusive objects. If you also want, you can create XOR utility type for array, not only for two members.

P.S. BTW, I've found nice detailed explanation on this problem in typescript with solution: video

Sengulair avatar Oct 11 '24 18:10 Sengulair

@Sengulair nice idea while this works on a type level it currently is not working on a vue component see

vue palyground

we will get

Error: [@vue/compiler-sfc] Unresolvable type: TSConditionalType

src/Comp.vue
4  |  type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
5  |  
6  |  export type XOR<T, U> = T | U extends object
   |                          ^^^^^^^^^^^^^^^^^^^^
7  |    ? (Without<T, U> & U) | (Without<U, T> & T)
   |  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
8  |    : T | U;
   |  ^^^^^^^^^
    at $Re.error (https://play.vuejs.org/assets/index-CVeOR0Tn.js:187:397)

alexanderop avatar Dec 15 '24 11:12 alexanderop

This is a type restriction of defineComponent, as a current solution you can use generic. This will bypass defineComponent and define the component as a functional component.

Indeed, with using generic components (and ignoring the generic) all cases are at least possible to work around with TypeScript and never (I've talked about that in a recent video)!

The only thing missing is a helper type as @alexanderop mentioned - as the XOR Type from above sadly breaks in the Vue Component. Having an easier way to define these "mutually exclusive" prop combinations would be ideal.

TheAlexLichter avatar Jan 31 '25 14:01 TheAlexLichter

What about a hypothetical syntax like this?

const props = defineProps<{
  color?: 'white' | 'black' | 'red';
  appearance?: 'outline' | 'solid' | 'ghost';
} & {
  constraints?: [
    ['color:white', 'appearance:outline'], // "white" requires "outline"
    ['appearance:outline', '!color:red'], // "outline" forbids "red"
  ];
}>();

nathanchase avatar Jan 31 '25 19:01 nathanchase