---
title: "Typing props and emits in script setup · vuejs.school"
description: "Type a component's props, defaults, events and v-model once in script setup, and let vue-tsc catch the mistakes the browser never sees."
canonical_url: "https://vuejs.school/community/blog/typing-props-and-emits-in-script-setup"
meta:
  "og:description": "Type a component's props, defaults, events and v-model once in script setup, and let vue-tsc catch the mistakes the browser never sees."
  "og:image": "https://vuejs.school/_og/d/a_~VHlwaW5nIHByb3BzIGFuZCBlbWl0cyBpbiBzY3JpcHQgc2V0dXAgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Typing%2Bprops%2Band%2Bemits%2Bin%2Bscript%2Bsetup%2Cdescription_~VHlwZSBhIGNvbXBvbmVudCdzIHByb3BzLCBkZWZhdWx0cywgZXZlbnRzIGFuZCB2LW1vZGVsIG9uY2UgaW4gc2NyaXB0IHNldHVwLCBhbmQgbGV0IHZ1ZS10c2MgY2F0Y2ggdGhlIG1pc3Rha2VzIHRoZSBicm93c2VyIG5ldmVyIHNlZXMu%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy90eXBpbmctcHJvcHMtYW5kLWVtaXRzLWluLXNjcmlwdC1zZXR1cCI%2Cs_f41cur6vtVP9Mlmm.png"
  "og:image:alt": "Typing props and emits in script setup · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Typing props and emits in script setup · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~VHlwaW5nIHByb3BzIGFuZCBlbWl0cyBpbiBzY3JpcHQgc2V0dXAgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Typing%2Bprops%2Band%2Bemits%2Bin%2Bscript%2Bsetup%2Cdescription_~VHlwZSBhIGNvbXBvbmVudCdzIHByb3BzLCBkZWZhdWx0cywgZXZlbnRzIGFuZCB2LW1vZGVsIG9uY2UgaW4gc2NyaXB0IHNldHVwLCBhbmQgbGV0IHZ1ZS10c2MgY2F0Y2ggdGhlIG1pc3Rha2VzIHRoZSBicm93c2VyIG5ldmVyIHNlZXMu%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy90eXBpbmctcHJvcHMtYW5kLWVtaXRzLWluLXNjcmlwdC1zZXR1cCI%2Cs_f41cur6vtVP9Mlmm.png"
  "twitter:image:alt": "Typing props and emits in script setup · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.vue**Breaks***

```
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.vue**Works***

```
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 stuck**3 questions from the Typing props, emits and models quiz.

[**Practice now**](https://vuejs.school/learn/typescript/typing-props-emits-and-models?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 6 min read**TypeScript strict mode in a Vue project: the errors you'll hit and the fixes**](https://vuejs.school/community/blog/typescript-strict-mode-in-a-vue-project) [Release notes · 7 min read**TypeScript 7.0: the Go compiler lands, and what it means for vue-tsc**](https://vuejs.school/community/blog/typescript-7-the-go-compiler-lands)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/typing-props-and-emits-in-script-setup#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-04-01","description":"Type a component's props, defaults, events and v-model once in script setup, and let vue-tsc catch the mistakes the browser never sees.","name":"Typing props and emits in script setup","url":"https://vuejs.school/community/blog/typing-props-and-emits-in-script-setup","breadcrumb":{"@id":"https://vuejs.school/community/blog/typing-props-and-emits-in-script-setup#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/typing-props-and-emits-in-script-setup"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/typing-props-and-emits-in-script-setup#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-04-01","description":"Type a component's props, defaults, events and v-model once in script setup, and let vue-tsc catch the mistakes the browser never sees.","headline":"Typing props and emits in script setup","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~VHlwaW5nIHByb3BzIGFuZCBlbWl0cyBpbiBzY3JpcHQgc2V0dXAgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Typing%2Bprops%2Band%2Bemits%2Bin%2Bscript%2Bsetup%2Cdescription_~VHlwZSBhIGNvbXBvbmVudCdzIHByb3BzLCBkZWZhdWx0cywgZXZlbnRzIGFuZCB2LW1vZGVsIG9uY2UgaW4gc2NyaXB0IHNldHVwLCBhbmQgbGV0IHZ1ZS10c2MgY2F0Y2ggdGhlIG1pc3Rha2VzIHRoZSBicm93c2VyIG5ldmVyIHNlZXMu%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy90eXBpbmctcHJvcHMtYW5kLWVtaXRzLWluLXNjcmlwdC1zZXR1cCI%2Cs_f41cur6vtVP9Mlmm.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/typing-props-and-emits-in-script-setup#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/typing-props-and-emits-in-script-setup#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/typing-props-and-emits-in-script-setup#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Typing props and emits in script setup","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~VHlwaW5nIHByb3BzIGFuZCBlbWl0cyBpbiBzY3JpcHQgc2V0dXAgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Typing%2Bprops%2Band%2Bemits%2Bin%2Bscript%2Bsetup%2Cdescription_~VHlwZSBhIGNvbXBvbmVudCdzIHByb3BzLCBkZWZhdWx0cywgZXZlbnRzIGFuZCB2LW1vZGVsIG9uY2UgaW4gc2NyaXB0IHNldHVwLCBhbmQgbGV0IHZ1ZS10c2MgY2F0Y2ggdGhlIG1pc3Rha2VzIHRoZSBicm93c2VyIG5ldmVyIHNlZXMu%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy90eXBpbmctcHJvcHMtYW5kLWVtaXRzLWluLXNjcmlwdC1zZXR1cCI%2Cs_f41cur6vtVP9Mlmm.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~VHlwaW5nIHByb3BzIGFuZCBlbWl0cyBpbiBzY3JpcHQgc2V0dXAgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Typing%2Bprops%2Band%2Bemits%2Bin%2Bscript%2Bsetup%2Cdescription_~VHlwZSBhIGNvbXBvbmVudCdzIHByb3BzLCBkZWZhdWx0cywgZXZlbnRzIGFuZCB2LW1vZGVsIG9uY2UgaW4gc2NyaXB0IHNldHVwLCBhbmQgbGV0IHZ1ZS10c2MgY2F0Y2ggdGhlIG1pc3Rha2VzIHRoZSBicm93c2VyIG5ldmVyIHNlZXMu%2Ceyebrow_~VHV0b3JpYWwgwrcgOCBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy90eXBpbmctcHJvcHMtYW5kLWVtaXRzLWluLXNjcmlwdC1zZXR1cCI%2Cs_f41cur6vtVP9Mlmm.png"}]}
```