Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ComboboxRoot): fix ignored type error #1713

Open
wants to merge 1 commit into
base: v2
Choose a base branch
from

Conversation

realugi
Copy link
Contributor

@realugi realugi commented Mar 15, 2025

This pull request removes the @ts-expect-error from here:

const modelValue = useVModel(props, 'modelValue', emits, {
// @ts-expect-error ignore the type error here
defaultValue: props.defaultValue ?? (multiple.value ? [] : undefined),
passive: (props.modelValue === undefined) as false,
deep: true,
}) as Ref<T | T[]>

And converts it to the following:

const modelValue = useVModel(props as ComboboxRootProps<T>, 'modelValue', emits, {
defaultValue: props.defaultValue ?? (multiple.value ? [] : undefined),
passive: (props.modelValue === undefined) as false,
deep: true,
}) as Ref<T | T[]>

I hope this is acceptable.

Reasoning

In order to understand this change, we need to have a look at how useVModel infers the type of the defaultValue option.

The useVModel function takes 3 generic parameters, of which the first two are important in this case, since they are used for the type of the defaultValue option:

  1. P extends object
  2. K extends keyof P
  3. Name extends string (unimportant in this case, but included for completion)

useVModel uses P and K in order to set the first generic parameter of UseVModelOptions to P[K]. And the first generic parameter of UseVModelOptions is used to set the type of the defaultValue option.

The generic parameter P will be infered to typeof props, since we pass it the props object. And the K parameter will be infered to 'modelValue', since we pass it the 'modelValue' string. And we know that the defaultValue option has the type P[K] in the generic case, so in this specific case it would have the type (typeof props)['modelValue'].

Now when setting the defaultValue option (type (typeof props)['modelValue']) to props.defaultValue (type T | T[] | undefined from ComboboxRootProps['defaultValue']), we get the following type mismatch error:
2025-03-15_17-53
(Note: I used a temporary variable here with the same type as defaultValue option, in order to make the error message more clear)

So it seems, that the type of (typeof props)['modelValue'] is not T | T[] | undefined like we would expect, but rather (T | T[] | undefined) & boolean. The & boolean part is added here by defineProps, but I do not know why. Maybe it is there due to an edge case for the boolean casting vue feature? I am not sure.

In any case, the (T | T[] | undefined) & boolean type is wrong and it should really be T | T[] | undefined, which is achieved by explicitly casting props to ComboboxRootProps. Then P would be infered to ComboboxRootProps and P[K] to ComboboxRootProps['modelValue'], which is exactly T | T[] | undefined.

There may be more solutions to this problem, but I chose the props as ComboboxRootProps<T> solution, because the props variable is really a type of ComboboxRootProps<T>, with a little type acrobatics added by vue's defineProps. But since vue's type is wrong in this case, I cast it back to ComboboxRootProps<T> just for the useVModel call.

Copy link

pkg-pr-new bot commented Mar 15, 2025

Open in Stackblitz

npm i https://pkg.pr.new/reka-ui@1713

commit: 69da1b1

@realugi
Copy link
Contributor Author

realugi commented Mar 18, 2025

After contemplating on this for a while, I think I know now why this error occurs.
It seems to be a typescript limitation.

I will post a reduced typescript playground example at the end of this post, which shows this limitation clearly. However, I will build up to the reduction, in order to show why the reduction is indicative of the typescript error with defaultValue.

So we know that defaultValue of the useVModel option extects a type of (T | T[] | undefined) & boolean. We know that this type comes from (typeof props)['modelValue']. We know that (typeof props) is the type of defineProps<ComboboxRootProps<T>>.

Looking at the type definition of defineProps (there are 3, I have only included the one relevant to this case):

export declare function defineProps<TypeProps>(): DefineProps<LooseRequired<TypeProps>, BooleanKey<TypeProps>>;
export type DefineProps<T, BKeys extends keyof T> = Readonly<T> & {
    readonly [K in BKeys]-?: boolean;
};
type BooleanKey<T, K extends keyof T = keyof T> = K extends any ? [T[K]] extends [boolean | undefined] ? K : never : never;

We can see here, how Vue implements boolean casting. Vue extracts all the potential boolean keys out of our ComboboxRootProps<T> type via the BooleanKey type. The BooleanKey type inspects each key of ComboboxRootProps<T> and see if its type T[K] extends boolean | undefined. If it does, then the key is included in the BooleanKey type.

Now when BooleanKey gets to the modelValue of ComboboxRootProps<T>, it asks whether it extends boolean | undefined. Specifically, it asks whether (T | T[] | undefined) extends boolean | undefined. And since the T type is generic, it might be a boolean, which is why at the end, we get a (typeof props)['modelValue'] of type (T | T[] | undefined) & boolean.

But wait. Can T of ComboboxRootProps<T> even possibly be a boolean? Lets have a look at the definition of T:

<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">

So T extends AcceptableValue. Maybe AcceptableValue could be a boolean?

// Exclude `boolean` type to prevent type casting
// reference: https://vuejs.org/guide/components/props.html#boolean-casting
type AcceptableValue = string | number | Record<string, any> | null

No It can not. There is even a comment, which says to exclude booleans from AcceptableValue due to vue's boolean casting.

With this information at hand, it seems that typescript does not consider T extends AcceptableValue when determining if (T | T[] | undefined) extends boolean | undefined.

I have also created a reduction, which shows that typescript generally does not consider the extends constraint inside the generic parameter for conditional types:

function test<T extends true>() {
    let a: T extends true ? number : string = 1;
    console.log(a);
}

Playground Link

We get the error Type 'number' is not assignable to type 'T extends true ? number : string'. But I think typescript should know that T can only be of type true (since we constrained it that way) and that therefore, the type of a can only be number. I hope you can see the similarities to our case above.

I belive that if typescript had this ability to infer type of a to be number, then it would also correclty infer that (typeof prop)['modelValue] can not possibly be a boolean and the code would work without any change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant