skip to content

A Vue 3.5 component declares `size?: 'sm' | 'md' | 'lg'` and `disabled?: boolean` with type-based defineProps; what does Vue actually check and change at runtime?

level: seniorimportance: should knowfreq 35%

answer

  1. literal unions become a plain constructor
  2. the type checker holds the real contract
  3. absent booleans are cast
  4. the type knows about the cast
  5. production drops the checks

basics

~20 s

At runtime size is only checked as a String, and only in development, so 'xl' passes silently; the literal union is enforced only by vue-tsc. disabled gets Boolean casting: absent means false, and its type is boolean, not optional.

solid answer

~40 s

The compiler turns each prop type into a runtime constructor, and a literal union `'sm' | 'md' | 'lg'` becomes just `String`. So a dynamic `'xl'` from API data passes the development check without a warning; only `vue-tsc` catches a wrong literal, and only where the value's type is known statically. Production builds drop the type checks entirely. `disabled?: boolean` becomes a `Boolean` prop, which keeps its type even in production because it changes behaviour: **Boolean casting** makes an absent `disabled` read as `false`, not `undefined`, and a bare `<SelectInput disabled>` read as `true`. Vue's types model this: `defineProps` marks boolean props as non-optional, so `props.disabled` is `boolean`. Emits work the same way: payload types are never checked at runtime. Where untrusted data needs checking, use a runtime declaration with a `validator`.

go deeper

for a junior

Recall that absent boolean props read as false and that TypeScript types are not fully checked at runtime.

for a middle

Explain that literal unions compile to a String check and that prop warnings exist only in development.

for a senior

Identify where untyped data bypasses vue-tsc and add runtime validation at the boundary or a validator on the prop.

for a principal

Decide how much runtime validation shared components carry versus trusting typed consumers and CI type checks.

## Two layers of checking A type-based `defineProps` produces **two** contracts from one type: - a **type-level contract**, enforced by `vue-tsc` and the editor over your templates and scripts; - a **runtime declaration**, generated by the SFC compiler, which Vue uses to separate props from attributes, apply defaults and casting, and (in development) warn about obvious mistakes. The second is much coarser than the first. Interviewers probe this gap because it is where production surprises come from. ## Literal unions become `String` ```ts const { size = 'md', disabled } = defineProps<{ size?: 'sm' | 'md' | 'lg' disabled?: boolean }>() ``` The compiler maps `'sm' | 'md' | 'lg'` to the runtime constructor **`String`**. At runtime: 1. `size="xl"` written literally in a typed parent template is a **type error** caught by `vue-tsc`. 2. `:size="settings.size"` where `settings` comes from an API typed as `string` is caught only if the types are honest; if the data is `any` or cast, nothing complains. 3. At runtime, `'xl'` is a string, so the development check **passes without a warning**. The component then renders with an unexpected value. ## Boolean props are cast `disabled?: boolean` becomes a **`Boolean`** runtime prop, and Boolean props get **Boolean casting**: | Parent writes | `disabled` reads | |---|---| | Nothing | `false` (not `undefined`) | | `disabled` (bare attribute) | `true` | | `:disabled="false"` | `false` | Vue's `defineProps` types reflect this: boolean keys are made **non-optional** on the returned type, so `props.disabled` is `boolean`, not `boolean \| undefined`. That is also why the compiler keeps the `Boolean` type in **production** output while dropping other types: casting is behaviour, not validation. ## Production output In a production build the compiler keeps each prop's key, `Boolean` types, defaults and some `Function` types, and removes the rest, because prop validation warnings exist only in development anyway. So nothing about `size` is checked in production at all. ## Emits: names only The same principle applies to `defineEmits<{ change: [value: string] }>()`: the runtime receives only the event names. A wrong payload type is caught by `vue-tsc` or not at all. ## When types are not enough - **Untrusted or dynamic data** (API responses, query strings, local storage) is not proven by TypeScript. Validate it where it enters the app, or give the component a **runtime declaration with a `validator`**, which checks values in development. - **Shared component libraries** used from untyped code or other build setups cannot rely on consumers running `vue-tsc`. - **Everyday application code** with typed data flow is usually well served by type-based props plus `vue-tsc` in CI. ## A worked failure A settings page loads the user's preferred size from an API and renders `<SelectInput :size="prefs.size" />`: 1. The API client types `prefs` loosely (`any`, or a cast), so `vue-tsc` has nothing to check. 2. A backend change starts returning `'xl'`. 3. In development the prop check sees a string and stays silent; in production there is no check at all. 4. The component builds a class name such as `select--xl`, which has no styles, and the control renders at the wrong size. The fix is not to make Vue stricter at the prop but to **type the boundary**: parse the API response into the `'sm' | 'md' | 'lg'` union (falling back to `'md'`), after which `vue-tsc` does prove every usage. A runtime `validator` on the prop is a reasonable extra guard for a shared component, because it turns the silent case into a development warning. ## Where `vue-tsc` does catch it - Literal values in templates: `size="xl"`. - Typed variables: `:size="userSize"` where `userSize` is a `string`, not the union. - Emits: `emit('change', 42)` against a `[value: string]` tuple. It cannot see values that entered the program as `any` or through a cast, which is exactly where the runtime is also blind. ## Pitfalls 1. Believing the literal union is enforced at runtime. 2. Writing `props.disabled === undefined` checks: an absent Boolean prop is `false`. 3. Declaring `flag?: boolean | string` and being surprised by casting rules; the order of `Boolean` and `String` in a union affects how an empty attribute is cast. 4. Relying on development warnings as a safety net: they vanish in production.

  • Why is `props.disabled` typed `boolean` rather than `boolean | undefined` for `disabled?: boolean` in Vue?
    Because Boolean casting guarantees a value: an absent Boolean prop is `false` at runtime. Vue's `defineProps` return type encodes this by making boolean keys required on the returned type, so the type matches what the component actually reads.
  • How would you make a Vue prop reject 'xl' at runtime rather than only in the type checker?
    Use a runtime declaration with a `validator`, for example `size: { type: String, validator: (v: string) => ['sm', 'md', 'lg'].includes(v) }`. In development Vue warns when it returns false. Type-based props cannot carry validators; production builds skip validation either way.

A type-based prop is like a guest list checked by the organiser before the party (vue-tsc at build time); at the door there is only a bouncer who checks that each guest is a person (a String), not whether their name is on the list, and after opening night even the bouncer goes home (production).

saying these in an interview costs you the question

  • Vue validates a string literal union prop against its allowed values at runtime.
  • An absent optional boolean prop reads as undefined.
  • Production builds keep all prop type checks active.
  • Type-based emits check payload types at runtime.
  • The TypeScript type of an optional boolean prop is boolean | undefined.