TypeScript's `defineProps` macro does not properly map to the object version in the case of booleans
What problem does this feature solve?
Vue has an unexpected behavior where if you do this:
props: {
foo: {
type: Boolean,
required: false
}
}
...the value of foo
, if not specified, will be false
instead of undefined
. This is somewhat unexpected, as noted in https://github.com/vuejs/vue/issues/7646 and https://github.com/vuejs/vue/issues/4792, but my guess is that this was a conscious choice by Vue to imitate the behavior certain HTML values, at least their values in the DOM.
The problem comes in the TypeScript extension of Vue. Say you have this:
<script setup lang="ts">
interface Props {
foo?: boolean
}
defineProps<Props>()
</script>
TypeScript is very clear about interface syntax, which is that foo?: boolean
means that the value of foo
will be undefined
if it is not specified. The type, in fact, is not boolean
but boolean | undefined
from TypeScript's perspective.
Meaning, the most accurate translation of TypeScript to Vue's JavaScript API when passing an interface would be:
props: {
foo: {
type: Boolean,
required: false,
default: undefined
}
}
I would consider this a bug of not interpreting a TypeScript interface accurately, BUT it could be also considered a feature request as supporting optional properties in TypeScript, which may have not been fully implemented.
What does the proposed API look like?
Ideally, the API would be identical:
<script setup lang="ts">
interface Props {
foo?: boolean
}
defineProps<Props>()
</script>
However, it's rational that there would be concern about breaking changes, so this is what I propose, which is a compile-time error vs a possible runtime change:
- If a TypeScript type is passed into
withDefaults(defaultProps<Props>, {})
, anyboolean
type should be required in thewithDefaults
object, so that the developer can make it clear if they are intending the default Vue behavior, or the default TypeScript behavior. - Ideally, a
withDefaults
macro would be required with adefaultProps
macro if, again, there are boolean types in the interface, but that could be considered too high of a burden for developers.
So, my proposal, at minimum, is that the provided script block throws an error (or I can set a TypeScript strictness check to throw an error?). Thanks for considering this.
FYI, the return type of defineProps
does take this into account:
interface Props {
foo?: boolean
}
const p = defineProps<Props>()
// ^ { foo: boolean }
p.foo
// ^ boolean
The interface declares foo
as being optional, but it is not the type of the resolved runtime props. The resolved props type correctly excludes the undefined
type, making it consistent with the runtime behavior.
The default casting behavior is indeed something that we cannot easily change due to potential breakage, but I don't think your case should be considered an error either.
When using an explicit undefined
default value with withDefaults
, the previous return type does have a bug where it still resolves the type to boolean
- this is fixed by #8602.
@yyx990803
This statement didn't make sense to me:
The interface declares foo as being optional, but it is not the type of the resolved runtime props. The resolved props type correctly excludes the undefined type, making it consistent with the runtime behavior.
I'm not sure what you mean "correctly excludes". It's not correct according to TypeScript and the runtime does support undefined
as a default value, so it's perfectly consistent to include it as the default, and booleans defaulting to false was a non-sensical design choice.
@yyx990803 Here's an even worse, more egregious example, where Vue's type inference is way off-base.
I made a component with these props (I've reduced it to the specific problem):
interface Props {
error?: string | true
}
defineProps<Props>()
Now, in this case, the error
can only have 3 possible value types: the value true
, a type of string
, or undefined
.
The safest thing to infer as a default value? Obviously, it's undefined
, according to the types. Instead, error
has a default value of false
, a value that should not be possible.
In fact, if I write this:
if (props.error === false) {}
...I get the error This comparison appears to be unintentional because the types 'ReactiveVariable<string> | ReactiveVariable<true> | undefined' and 'false' have no overlap.
TypeScript itself is stating (correctly) that Vue is not handling types correctly, is abusing the type system, and is assigning values which violate those types and which could lead to runtime errors.
This is a bug. Please re-open.
At the very least, can there be changes to the Vite / Webpack plugins to fix this behavior for the defineProps
macro and correctly reflect TypeScript types in the runtime (and in Volar), if the Vue team does not want to create a breaking change?
Something like:
vue({
script: {
accurateTypes: true
}
})
I've found workaround is to use uppercase/class version of Boolean
. Most likely your linter will complain about this. 😄But it works, and I get undefined instead of false.
I've created useForwardProps as another workaround for this issue as it is quite crucial for radix-vue.
I've also pointed out some pain point and challenges here https://github.com/vuejs/vue/issues/4792#issuecomment-1751643093
@SnosMe Your comment is great! I think that's actually a perfect* near perfect workaround. I paired it with this in my ESLint config:
'@typescript-eslint/ban-types': [
'error',
{
types: {
Boolean: false
},
extendDefaults: true
}
]
- EDIT: I think, as far as I know, that this still causes heaps of trouble with a type like this:
interface Props {
error?: string | true
}
defineProps<Props>()
As I note in that comment, there are only 3 possible values based on that type: undefined
, true
, and a string. What does Vue do? It sets it to false
, a completely invalid value. (Note: this was a real type I was dealing with where validation functions could return true (valid) or a string which represented an error message. Vuetify uses this pattern.)
So, with a type like that, you're kind of outta luck unless you use withDefaults I guess? 🤔