Vue 3 com TypeScript em 2026: props, emits e composables com tipos seguros

Padrões de Vue 3 e TypeScript para 2026: defineProps, defineEmits, composables, defineModel e provide/inject tipados, com exemplos práticos e perguntas de entrevista.

Vue 3 TypeScript: props, emits e composables com tipos seguros

O Vue 3 com TypeScript em 2026 oferece aos componentes verificação estática completa de tipos em props, eventos e lógica reutilizável. A sintaxe <script setup>, combinada a macros do compilador como defineProps e defineEmits, transforma o que antes eram definições validadas apenas em tempo de execução em contratos verificados em tempo de compilação. Este guia cobre os padrões tipados que todo desenvolvedor Vue precisa dominar, tanto para código de produção quanto para entrevistas técnicas.

Props tipadas em uma única linha

Desde o Vue 3.5+, a forma genérica defineProps<T>() infere os tipos das props diretamente de uma interface TypeScript — sem campos type em tempo de execução, sem cast com PropType. Combinada com a desestruturação reativa de props, os valores padrão ficam na mesma linha: const { size = 'md' } = defineProps<Props>().

Tipando props com defineProps e interfaces TypeScript

A declaração de props em tempo de execução (props: { title: String }) funciona, mas duplica a informação de tipo e perde precisão. Tipos união, objetos aninhados e assinaturas de função não podem ser expressos de forma limpa. A forma genérica de defineProps resolve isso ao tomar o formato de um tipo TypeScript, que o compilador do Vue então converte automaticamente na declaração de execução correta.

vue
<!-- UserCard.vue -->
<script setup lang="ts">
interface Props {
  userId: number            // required primitive
  name: string
  role: 'admin' | 'member'  // union type, impossible with runtime props
  tags?: string[]           // optional array
  onSelect?: (id: number) => void  // typed callback prop
}

const props = defineProps<Props>()
</script>

<template>
  <article @click="props.onSelect?.(props.userId)">
    <h3>{{ props.name }}</h3>
    <span>{{ props.role }}</span>
  </article>
</template>

A prop role agora fica restrita a duas strings literais, de modo que passar role="guest" faz o build falhar. Essa é a principal razão para recorrer a defineProps com TypeScript: o guia oficial de TypeScript do Vue trata a forma genérica como o padrão nos projetos que usam <script setup>.

Valores padrão: withDefaults versus desestruturação reativa

Props apenas tipadas não têm, por si só, um mecanismo de valor padrão em tempo de execução. Historicamente, a resposta era withDefaults, que envolve a macro e mescla um objeto de valores padrão:

vue
<!-- Button.vue -->
<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
}

// withDefaults keeps reactivity and applies fallback values
const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'md',
  disabled: false,
})
</script>

O Vue 3.5 estabilizou a desestruturação reativa de props, hoje o padrão mais conciso. Desestruturar o resultado de defineProps e atribuir um valor padrão na mesma instrução continua totalmente reativo: o compilador reescreve cada acesso de volta para props.x nos bastidores.

vue
<!-- Badge.vue -->
<script setup lang="ts">
interface Props {
  label: string
  color?: 'green' | 'red'
  outlined?: boolean
}

// Defaults are declared inline; each variable stays reactive
const { label, color = 'green', outlined = false } = defineProps<Props>()
</script>

<template>
  <span :class="[color, { outlined }]">{{ label }}</span>
</template>
A armadilha de reatividade com props desestruturadas

Props desestruturadas continuam reativas no template e em um computed, mas passar um valor desestruturado diretamente para watch ou para um composable o lê uma única vez e rompe o vínculo reativo. É preciso envolvê-lo em um getter — watch(() => color, ...) — ou convertê-lo com toRef(props, 'color') quando uma ref é necessária adiante.

Essa é uma armadilha comum em entrevistas: os candidatos presumem que a variável desestruturada é um valor simples, quando na verdade o compilador redirecionou cada leitura para props.color.

Emits com tipos seguros usando defineEmits

Os eventos merecem o mesmo rigor que as props. A forma genérica de defineEmits descreve cada nome de evento e sua carga útil como uma tupla, dando ao componente pai o autocompletar e ao componente filho a garantia, em tempo de compilação, de que os argumentos corretos são emitidos.

vue
<!-- SearchInput.vue -->
<script setup lang="ts">
// Tuple syntax: event name -> [ ...payload types ]
const emit = defineEmits<{
  search: [query: string]
  clear: []                       // no payload
  select: [id: number, label: string]  // multiple args
}>()

function onSubmit(value: string) {
  emit('search', value)   // ✅ typed
  // emit('search', 42)   // ❌ compile error: number not assignable to string
}
</script>

Essa forma de tupla substituiu a antiga sintaxe por assinatura de chamada ((e: 'search', q: string): void) porque se lê melhor e suporta vários eventos sem sobrecargas. Quando um evento não carrega dados, uma tupla vazia [] documenta isso de forma explícita. Combinar emits tipados com props tipadas produz componentes cuja interface pública inteira é verificável antes da execução — a mesma disciplina abordada no guia da API de composição do Vue.

Tipando composables para lógica reutilizável

Composables são funções comuns, então seguem as regras ordinárias do TypeScript, mas algumas convenções os mantêm ergonômicos. Convém anotar explicitamente os tipos Ref quando a inferência não é óbvia e usar genéricos quando um composable envolve dados arbitrários, como a resposta de uma API.

useFetch.tstypescript
import { ref, type Ref } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  loading: Ref<boolean>
}

// Generic <T> flows through to the caller's typed data
export function useFetch<T>(url: string): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const loading = ref(true)

  fetch(url)
    .then((r) => r.json())
    .then((json: T) => { data.value = json })
    .catch((e: Error) => { error.value = e })
    .finally(() => { loading.value = false })

  return { data, error, loading }
}

No ponto de chamada, o parâmetro genérico deixa data totalmente tipado sem nenhuma anotação adicional:

UserList.vue (script setup)typescript
interface User { id: number; name: string }

// data is Ref<User[] | null> — inferred from the generic
const { data: users, loading } = useFetch<User[]>('/api/users')

Anotar explicitamente o objeto de retorno (UseFetchReturn<T>) vale essas poucas linhas extras: documenta o contrato, evita vazamentos acidentais de refs internas e dá aos consumidores um único tipo para importar. Para padrões de composables mais avançados, como sobrecargas de argumentos e limpeza ligada ao ciclo de vida, consulte o guia avançado de composables do Vue.

Pronto para mandar bem nas entrevistas de Vue.js / Nuxt.js?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Tipando defineModel para binding bidirecional

O defineModel, estável desde o Vue 3.4, condensa o antigo par de prop modelValue mais emit update:modelValue em uma única ref gravável. Seu parâmetro genérico tipa os dois sentidos do binding ao mesmo tempo.

vue
<!-- CurrencyInput.vue -->
<script setup lang="ts">
// model is Ref<number>; parent v-model is type-checked as number
const model = defineModel<number>({ required: true })

function increment() {
  model.value++   // writing back updates the parent
}
</script>

<template>
  <input :value="model" type="number" @input="model = Number(($event.target as HTMLInputElement).value)" />
  <button @click="increment">+1</button>
</template>

Modelos nomeados — defineModel<string>('title') — correspondem a v-model:title e recebem a mesma tipagem. Isso elimina uma categoria inteira de bugs de carga útil inconsistente que o padrão manual de prop e emit costumava esconder.

Tipando refs de template e instâncias de componente

Acessar um nó do DOM ou um componente filho por meio de uma ref é onde o código Vue sem tipos mais frequentemente recai em any. A correção é parametrizar useTemplateRef (Vue 3.5+) ou a própria ref com o tipo do elemento, para que o acesso às propriedades seja verificado contra a interface real do DOM.

vue
<!-- FocusField.vue -->
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'

// The ref is typed as HTMLInputElement | null
const inputRef = useTemplateRef<HTMLInputElement>('field')

onMounted(() => {
  // .focus() and .select() are known because the element type is explicit
  inputRef.value?.focus()
})
</script>

<template>
  <input ref="field" placeholder="Type to search" />
</template>

Para uma referência a um componente filho, InstanceType<typeof Child> extrai o tipo público do componente, expondo tudo o que o filho declarou por meio de defineExpose. Assim, as chamadas de método do pai para o filho permanecem totalmente verificadas em vez de adivinhadas.

vue
<!-- Parent.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import Modal from './Modal.vue'

// Ref is typed to Modal's exposed API (e.g. open / close methods)
const modal = useTemplateRef<InstanceType<typeof Modal>>('modal')

function launch() {
  modal.value?.open()   // ✅ checked against Modal's defineExpose
}
</script>

provide e inject com tipos seguros usando InjectionKey

A injeção de dependências através da árvore de componentes perde a informação de tipo a menos que a chave a carregue. InjectionKey<T> é um símbolo tipado que vincula o tipo de um valor à sua chave, de modo que provide e inject permanecem sincronizados sem cast manual.

theme-key.tstypescript
import type { InjectionKey, Ref } from 'vue'

export interface ThemeContext {
  mode: Ref<'light' | 'dark'>
  toggle: () => void
}

// The key permanently associates the ThemeContext type with this symbol
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
typescript
// provider (script setup) — value must match ThemeContext or it fails to compile
provide(ThemeKey, { mode, toggle })

// consumer — theme is inferred as ThemeContext | undefined
const theme = inject(ThemeKey)
theme?.toggle()

Fornecer um valor cujo formato não corresponde a ThemeContext provoca um erro de compilação no ponto de injeção, capturando qualquer desvio entre componentes distantes antes de chegar à produção.

Perguntas comuns de entrevista sobre Vue e TypeScript

Os entrevistadores investigam se o candidato compreende a fronteira entre os tipos de compilação e o comportamento em tempo de execução. Algumas perguntas recorrentes e suas respostas precisas:

  • Por que preferir defineProps<T>() à forma objeto de execução? As props genéricas expressam tipos união, assinaturas de função e formatos aninhados que as declarações de execução não conseguem capturar, e removem a duplicação entre a definição de tipo e a de execução.
  • As props desestruturadas são reativas? Sim, desde o Vue 3.5+, porque o compilador reescreve o acesso para props.x. Mas um valor desestruturado passado para watch ou para um composable é lido uma única vez — use um getter ou toRef.
  • Como tipar um evento emitido sem carga útil? Com uma tupla vazia: defineEmits<{ close: [] }>().
  • O que o defineModel substitui? O par de prop modelValue e emit update:modelValue, unificado em uma única ref gravável tipada.

Praticar contra um banco de perguntas real afia os reflexos que as entrevistas recompensam — o módulo de entrevista sobre composables do Vue treina exatamente esses padrões. A ferramentaria também importa: rode vue-tsc na CI para que os erros de tipo bloqueiem os merges, e apoie-se no manual do TypeScript quando os genéricos de composables entram em cena. A experiência no editor é impulsionada pela ferramentaria oficial Volar do Vue, que lê essas macros para sinalizar os erros em linha.

Conclusão

  • Usar a forma genérica defineProps<Props>() para expressar tipos união, campos opcionais e props callback que as declarações de execução não conseguem capturar.
  • Preferir a desestruturação reativa de props com valores padrão em linha a partir do Vue 3.5+, e recorrer a withDefaults apenas quando um objeto de valores padrão compartilhado for mais claro.
  • Tipar os eventos com a forma de tupla de defineEmits para que as cargas úteis sejam verificadas em tempo de compilação tanto no filho quanto no pai.
  • Anotar explicitamente os tipos de retorno dos composables e usar genéricos para encaminhar de ponta a ponta os tipos de dados fornecidos por quem chama.
  • Adotar defineModel<T>() para o binding bidirecional a fim de condensar o boilerplate de prop mais emit em uma única ref tipada.
  • Rodar vue-tsc na integração contínua para que as regressões de tipo façam o build falhar em vez de chegar à produção.

Comece a praticar!

Teste seus conhecimentos com nossos simuladores de entrevista e testes tecnicos.

Tags

#Vue 3
#TypeScript
#defineProps
#Composables
#Frontend

Compartilhar

Artigos relacionados