Typing props, emits and models
Know which part of a typed defineProps(), defineEmits() and defineModel() TypeScript checks and which part Vue still checks in the browser.
What you'll learn
When you finish, you can do these.- 1Type props with defineProps() and a list of allowed values
- 2Predict whether Vue or only vue-tsc catches a wrong prop
- 3Type what an event carries and what a v-model holds
- 4Fix a watch() on a destructured prop with a getter
Picture a shop page with price tags. Each tag is a Vue component: the page passes it props, values such as an amount and a currency, and the tag sends events back up when someone clicks it. A TypeScript type can say the currency is EUR or USD, nothing else. That type is read twice: by TypeScript before the code runs, and by Vue in the browser, which keeps only part of it. One wrong value warns, one breaks the tag, and one passes straight through to the shopper.
The Code tab holds that page, App.vue, with two tags from PriceTag.vue and a quantity control, Stepper.vue, whose number the page shares through v-model. By the end you can type props, events and v-model, and say which checker catches which mistake. You need one idea from the lesson Type checking and editor setup: TypeScript errors show only in your editor and in vue-tsc, a command that checks the whole project. A running page never shows them, the Code tab's preview included.
Two checkers read one type
A component's props are typed once, and two checkers read that type. In PriceTag.vue, the type goes between the angle brackets of defineProps(), the function that declares props:
<script setup lang="ts">
const { amount, currency = 'EUR', sale = false } = defineProps<{
amount: number
currency?: 'EUR' | 'USD'
sale?: boolean
}>()
</script>
A ? marks a prop the parent, here App.vue, may leave out. The const line destructures the props, taking them apart into three names, and gives currency a default: a tag with no currency shows euros.
TypeScript reads the whole type. 'EUR' | 'USD' is a union of two literal types: exactly the string EUR or the string USD. The editor offers both as you type, and vue-tsc rejects any other value in a parent's template.
In the browser the types no longer exist, so Vue's compiler turns each prop into a runtime declaration, a check that exists while the code runs: amount: { type: Number, required: true } and currency: { type: String, required: false, default: 'EUR' }. The literal union became String. In development Vue checks each prop's base type and that required props are present. For events it checks only the name.
So one tag reacts three ways to three mistakes. Take the second tag in App.vue, which shows $40.00 from :amount="40" and currency="USD":
:amount="'12'"passes a string where the type says number. Vue can see that, so the preview showsInvalid prop: type check failed for prop "amount". Expected Number with value 12, got String with value "12".currency="GBP"is a string, so Vue is satisfied and the tag shows £40.00. Only vue-tsc complains:Type '"GBP"' is not assignable to type '"EUR" | "USD" | undefined'.currency="EURO"is a string too, so Vue stays quiet, and the tag breaks, as What goes wrong shows.
Typed events and models
Events and v-model, the other two links between a component and its parent, are typed the same way. The price tag sends two events: select when you click the price, and clear from its × button. PriceTag.vue declares them with defineEmits():
const emit = defineEmits<{
select: [amount: number]
clear: []
}>()
Each event is listed with its payload, the values it carries, as a named tuple, a list of named values: select carries one number called amount, and clear carries nothing.
TypeScript checks both ends of an event. Inside the tag, emit('select', 'oops') sends a string and fails with Argument of type 'string' is not assignable to parameter of type 'number'. In App.vue, the listener @select="picked = $event" gets the payload as $event, typed as a number. At runtime Vue checks neither: the string would reach the parent.
A v-model keeps one value the same in a parent and a child: v-model="qty" in App.vue ties its qty to the stepper's count. The minus and plus buttons change count by step, a prop for how much one click adds or removes. Stepper.vue types the model with defineModel<number>(), and the options decide the type of count, the ref it returns.
const count = defineModel<number>({ required: true })
- With
required: true,countis aRef<number>, a ref holding a number, and Vue warns if a parent leaves outv-model. - With no options,
countis aRef<number | undefined>, socount += stepin the template fails with'count.value' is possibly 'undefined'. - With
default: 0,countis aRef<number>again, with a catch.
What goes wrong
Two mistakes look harmless: one stops Stepper.vue compiling, and only vue-tsc catches the other.
The Step size button in App.vue switches the stepper's step between 1 and 5, and the stepper records each change in a note through watch(), which runs code when a value changes. Passing watch() the destructured prop step itself stops the component from compiling.
const { step = 1 } = defineProps<{ step?: number }>()
watch(step, (now, before) => {
note.value = `Step size changed from ${before} to ${now}`
})
The preview shows "step" is a destructured prop and should not be passed directly to watch(). Pass a getter () => step instead. In script, step is the number the prop holds at that moment, and watch() cannot track a plain number. A getter, a function that returns the prop, reads it again on every change.
const { step = 1 } = defineProps<{ step?: number }>()
watch(() => step, (now, before) => {
note.value = `Step size changed from ${before} to ${now}`
})
With the getter, click Step size: the note reads Step size changed from 1 to 5.
The second mistake, on the second tag, is a currency code that is a string but neither EUR nor USD.
<PriceTag :amount="40" currency="EURO" />
No Vue warning appears, because EURO is a string. Rendering the tag then fails: it disappears, and the preview shows Invalid currency code : EURO, a RangeError from the browser's number formatter, which writes the price. vue-tsc would have stopped it before the page ever ran: Type '"EURO"' is not assignable to type '"EUR" | "USD" | undefined'. Did you mean '"EUR"'?
<PriceTag :amount="40" currency="EUR" />
With EUR, the tag is back and shows €40.00.
What to remember
- TypeScript checks the whole type of every prop, event and model, in the editor and in vue-tsc. In the browser, Vue checks only base types, required props and event names.
- A literal union such as
'EUR' | 'USD'becomes a plain String check at runtime, so a wrong string passes the preview without a warning. defineEmitstypes each payload as a tuple, and the parent's$eventgets that type. Nothing checks the payload at runtime.defineModel<T>()returnsRef<T | undefined>unless you addrequired: trueor adefault, and adefaultthe parent does not share leaves the two sides out of sync.- Watch a destructured prop through a getter,
watch(() => step, …). Passingstepitself is a compile error.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In App.vue, change the second tag to
:amount="'12'". Predict what vue-tsc says, then look at the preview: the tag shows $12.00 and the preview warnsInvalid prop, expected Number, got String. In a project, vue-tsc reportsType 'string' is not assignable to type 'number'.Put:amount="40"back. - Change
currency="USD"tocurrency="GBP": the tag shows £40.00 and nothing warns, though vue-tsc would reject it. Add| 'GBP'to thecurrencytype in PriceTag.vue to make it legal. Then trycurrency="EURO": the second tag disappears and the preview showsInvalid currency code : EURO. - In Stepper.vue, change
watch(() => step, …)towatch(step, …). The preview shows the compile error that asks for a getter,() => step. Put the getter back and click Step size: the note reads Step size changed from 1 to 5.