Subscribe on changes!

TypeScript's `defineProps` macro does not properly map to the object version in the case of booleans

avatar
Jun 14th 2023

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:

  1. If a TypeScript type is passed into withDefaults(defaultProps<Props>, {}), any boolean type should be required in the withDefaults object, so that the developer can make it clear if they are intending the default Vue behavior, or the default TypeScript behavior.
  2. Ideally, a withDefaults macro would be required with a defaultProps 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.

avatar
Jul 11th 2023

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.

avatar
Jul 17th 2023

@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.

avatar
Jul 18th 2023

@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.

avatar
Jul 18th 2023

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
  }
})
avatar
Aug 24th 2023

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.

avatar
Oct 7th 2023

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

avatar
Jan 27th 2024

@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? 🤔