Skip to content
TutorialTypeScript

Typing props and emits in script setup

A component's props and events are its contract with the parent. Write that contract once, as types, and vue-tsc checks every use of the component before it reaches a browser.

vuejs.school team1 Apr 20258 min readVue 3.5 · TypeScript 5.8 · vue-tsc 2.2

When a parent renders <QuantityStepper :max="6" @limit="warn" />, three questions decide whether it works: which props the stepper accepts, which events it emits, and what each event sends with it (its payload). Without types, a mistake in any of them shows up only when the page runs, if at all.

In <script setup lang="ts"> you answer them once, as types. You will end with a stepper whose props, defaults, events and v-model are all typed, and a type check (vue-tsc) that rejects a missing prop or a handler of the wrong type before the code ships.

Declare props with a type literal

The examples assume a TypeScript project made with npm create vue@latest; file names are relative to its src/ folder. Start with two required props:

components/QuantityStepper.vue
<script setup lang="ts">
const props = defineProps<{
  quantity: number
  label: string
}>()
</script>

<template>
  <p>{{ props.label }}: {{ props.quantity }}</p>
</template>

defineProps is a compiler macro: a function that only exists at build time, so there is nothing to import. The angle brackets hold a type argument, an object type that lists each prop and its type. From that type the Vue compiler generates the runtime declaration, quantity: { type: Number, required: true }, so development builds still warn in the console when a parent passes a string. One declaration gives you both the runtime check and the static type.

Inside the script you read props through the returned object, props.quantity. In the template the props. prefix is optional, so {{ label }} works too. Pick one style per file. What you cannot do is mix a type argument with a runtime object: defineProps<...>({...}) is a compile error.

Optional props and their defaults

A question mark makes a prop optional, so max?: number is typed as number | undefined. Most optional props want a default instead, and in Vue 3.5 the way to give one is to destructure:

const { quantity, label, min = 0, max = 99, step = 1 } = defineProps<{
  quantity: number
  label: string
  min?: number
  max?: number
  step?: number
}>()

Since 3.5 the compiler keeps destructured props reactive: every use of min in the script is rewritten to a read from the props object (__props.min in the compiled code), so it updates when the parent changes it. The defaults use plain JavaScript syntax, and inside the component min is typed as number, not number | undefined, because the default is part of the destructure. Arrays and objects can be defaults as written, tags = [], with no wrapper function.

One habit changes with destructure. A destructured prop is a value, so watch(min, ...) would hand a number to watch. The compiler stops the build with "min" is a destructured prop and should not be passed directly to watch(). Pass a getter () => min instead. Do what it says: watch(() => min, ...), and pass () => min to composables as well.

If your project is on Vue 3.4, or a setting has turned destructure off, withDefaults does the same job:

const props = withDefaults(defineProps<{ min?: number; tags?: string[] }>(), {
  min: 0,
  tags: () => [],
})

Here array and object defaults must be functions, so that each instance gets its own copy.

Use one form or the other, not both. If you destructure the result of withDefaults, the compiler warns that withDefaults() is unnecessary and turns reactive destructure off for that component, so the destructured names keep their first values. On 3.5 destructure is shorter, and the rest of this post uses it.

Import a props interface from another file

Two components often take the same props: a size and a variant for every button, say. Since Vue 3.3 the type argument can be a type imported from another file:

types/stepper.ts
export interface StepperProps {
  label: string
  min?: number
  max?: number
  step?: number
}
components/QuantityStepper.vue
<script setup lang="ts">
import type { StepperProps } from '@/types/stepper'

const { label, min = 0, max = 99, step = 1 } = defineProps<StepperProps>()
</script>

The compiler opens types/stepper.ts itself to work out the runtime declaration, which is why Vue needs TypeScript installed in the project for this to work; any create-vue project with TypeScript has it, and its tsconfig.app.json maps @/* to ./src/*. The import should be import type, and the interface can come from a relative path, an alias such as @/types, or a package in node_modules.

There is a limit. The conversion from type to runtime declaration reads the type's syntax rather than asking TypeScript's checker, so types that need real type analysis are not accepted for the props object as a whole: conditional types, ReturnType<...> and the like. Intersections and Partial, Pick or Omit of an interface are fine. A single prop can have any type you want, including a conditional one; the restriction is only on the shape of the whole object.

Type events with named tuples

Events are declared with defineEmits, and since 3.3 the clearest form is an object type where each key is an event name and each value is a tuple describing the payload. In the stepper, add() emits limit at the edges of the range and change otherwise:

const emit = defineEmits<{
  change: [quantity: number]
  limit: [edge: 'min' | 'max']
}>()

function add(delta: number) {
  const next = quantity + delta
  if (next < min || next > max) {
    emit('limit', next < min ? 'min' : 'max')
    return
  }
  emit('change', next)
}

[quantity: number] is a named tuple: an array type with one element, labelled so that editors can show the name. An event with no payload is [].

The returned emit function is typed from the object. emit('changed', next) fails with No overload matches this call, and the lines under it say that "changed" is not assignable to "change" or to "limit". emit('change', 'two') fails the same way, because the payload is not a number.

The older syntax with call signatures, (e: 'change', quantity: number): void, still works and means the same thing. The tuple form is shorter and is what new code should use.

The types flow to the parent too. In <QuantityStepper @limit="onLimit" />, vue-tsc checks that onLimit accepts a 'min' | 'max'.

Type v-model with defineModel

The stepper takes quantity in and sends change out, which is exactly the pair that v-model packages up. defineModel, stable since Vue 3.4, declares the prop and the update event in one line:

const quantity = defineModel<number>({ required: true })

The type argument is the value type, and the options object accepts the same options as a prop. With required: true the result is a Ref<number>; without it, Ref<number | undefined>, because a parent might not bind anything.

A default also removes the undefined: defineModel<number>({ default: 1 }). Be careful with that one. If the parent binds a ref that holds undefined, the parent keeps undefined while the child shows 1, and the two only agree after the child's first change.

Named models take the name first, defineModel<number>('quantity'), and the parent writes v-model:quantity. Assigning to the ref emits update:modelValue, or update:quantity, for you.

Here is the finished component. quantity is now the model, and defineEmits keeps only limit:

components/QuantityStepper.vue
<script setup lang="ts">
import type { StepperProps } from '@/types/stepper'

const { label, min = 0, max = 99, step = 1 } = defineProps<StepperProps>()

const quantity = defineModel<number>({ required: true })

const emit = defineEmits<{
  limit: [edge: 'min' | 'max']
}>()

function add(delta: number) {
  const next = quantity.value + delta
  if (next < min || next > max) {
    emit('limit', next < min ? 'min' : 'max')
    return
  }
  quantity.value = next
}
</script>

<template>
  <div>
    <span>{{ label }}</span>
    <button type="button" @click="add(-step)">-</button>
    <output>{{ quantity }}</output>
    <button type="button" @click="add(step)">+</button>
  </div>
</template>

And a parent that uses every part of the contract:

App.vue
<script setup lang="ts">
import { ref } from 'vue'
import QuantityStepper from './components/QuantityStepper.vue'

const tickets = ref(1)
const notice = ref('')

function onLimit(edge: 'min' | 'max') {
  notice.value = edge === 'max' ? 'No more than 6 tickets per order.' : 'At least one ticket.'
}
</script>

<template>
  <QuantityStepper v-model="tickets" label="Tickets" :max="6" @limit="onLimit" />
  <p>{{ notice }}</p>
</template>

What vue-tsc checks that the browser does not

Everything above is erased before the code runs. In the browser, Vue's own checks are late and loose. In a development build, when the stepper renders, the console warns that the required label is missing, that :max="'6'" is a string, or that the template reads a lable that does not exist. Production builds skip these checks.

Other mistakes get no warning at all. Payload types are not checked at runtime, so emit('limit', 42) reaches the parent's handler. And a parent that writes @limt instead of @limit is never told, because Vue passes the unknown listener on to the stepper's root element.

vue-tsc checks every use of the component without running it. It is TypeScript's tsc with an understanding of .vue files, from the Vue language tools team, and the current line is 2.2. The missing label, the string max, emit('limit', 42), an onLimit that expects the wrong payload and the misspelt lable are all errors with a file and line; the last comes back as Property 'lable' does not exist on type .... The Vue extension for VS Code shows the same errors as you type.

The @limt typo still passes by default, because handing unknown listeners to the root element is legal Vue. If your components do not rely on that, "vueCompilerOptions": { "strictTemplates": true } in tsconfig.app.json makes unknown props and listeners errors too.

A project created with npm create vue@latest and TypeScript ships TypeScript 5.8 and vue-tsc 2.2, a type-check script that runs vue-tsc --build, and a build script that runs type-check alongside the Vite build. A wrong prop therefore fails npm run build, not a user's session. Run the check on its own while you work, and in CI if your pipeline does not build:

npm run type-check

Fix the two errors you will hit first

The first is 'props.max' is possibly 'undefined', or its cousin Type 'number | undefined' is not assignable to type 'number'. You declared max?: number, kept it optional, and then compared or added it:

components/QuantityStepper.vueBreaks
import { computed } from 'vue'

const props = defineProps<{ quantity: number; max?: number }>()

const atMax = computed(() => props.quantity >= props.max)

The fix is a default, which turns the type into number:

components/QuantityStepper.vueWorks
import { computed } from 'vue'

const { quantity, max = 99 } = defineProps<{ quantity: number; max?: number }>()

const atMax = computed(() => quantity >= max)

If a prop is genuinely optional and has no sensible default, narrow it where you use it, if (max !== undefined), or fall back with max ?? Infinity. Do not reach for the non-null assertion max!; it silences the compiler without handling the case where the parent passed nothing.

The second is Unresolvable type reference or unsupported built-in utility type. This one comes from the Vue compiler, not from TypeScript, and it means the props type is too clever for the syntax-based conversion described earlier. ReturnType<typeof ...> of an ordinary function triggers it, and so do utility types such as Record and NonNullable. A conditional type across the whole object fails with a related message, Unresolvable type: TSConditionalType.

The fix for both is to write the props out as a plain interface, in the component or in a .ts file you import as shown earlier. Keep the computed types for the values inside the props, not for the shape of the props object.

Check it stuck3 questions from the Typing props, emits and models quiz.
Practice now
Was this helpful?

Keep reading