Skip to the solution
MediumFrontend11 min read

How do you use TypeScript with Vue (typed props, emits, refs, vue-tsc)?

689 views

The problem

Question presented to candidate: "We are moving a Vue 3 app to TypeScript. Show me how you would type a component's props and emits, refs and template refs, and provide/inject, and tell me how type errors in templates get caught before they reach production."

What a strong answer should cover:

  • <script setup lang="ts"> with type-based defineProps<{...}>(); the compiler generates the runtime props declaration from the type. Defaults via reactive props destructure (Vue 3.5) or withDefaults.
  • Type-based defineEmits with the named-tuple syntax (change: [id: number], Vue 3.3+), so emit calls and parent handlers are checked.
  • ref<T>() for unions or nullable values, inferred otherwise; useTemplateRef (Vue 3.5) for DOM and component refs, typed from the template or explicitly.
  • InjectionKey<T> for typed provide/inject, remembering inject() may return undefined.
  • vue-tsc type-checks .vue files including templates; plain tsc cannot import .vue, and vite build strips types without checking, so CI must run vue-tsc --noEmit (create-vue wires it as type-check).
  • Types are compile-time only: Vue still validates props at runtime in development from the generated declaration.

Clarifying questions expected:

  • "Which Vue version are we on? Props destructure, useTemplateRef and the tuple emits syntax depend on it."
  • "Is the build already running vue-tsc, or only Vite?"

Code / implementation expected: Yes β€” a typed child with props and emits, a parent using it, and at least one real vue-tsc error.

  • vue
  • typescript
  • vue-tsc

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

11 min read Β· 12 sections

Read the code

VueTyped refs, computed, a template ref and a typed child component in one file
<script setup lang="ts">
import {
  ref, computed, reactive, useTemplateRef, onMounted, defineComponent, h,
  type PropType,
} from "vue";

type Status = "todo" | "done";
interface Task { id: number; title: string; status: Status }

// A typed child without a second file: runtime props + PropType, typed emits.
const TaskRow = defineComponent({
  props: {
    task: { type: Object as PropType<Task>, required: true },
  },
  emits: {
    toggle: (id: number) => Number.isInteger(id), // validator doubles as the type
  },
  setup(props, { emit }) {
    return () =>
      h("li", { onClick: () => emit("toggle", props.task.id) },
        `${props.task.status === "done" ? "[x]" : "[ ]"} ${props.task.title}`);
  },
});

const tasks = ref<Task[]>([
  { id: 1, title: "Write tests", status: "todo" },
  { id: 2, title: "Run vue-tsc", status: "done" },
]);
const draft = reactive({ title: "" });                       // inferred { title: string }
const remaining = computed(() =>                             // ComputedRef<number>
  tasks.value.filter((t) => t.status === "todo").length);
const input = useTemplateRef<HTMLInputElement>("newTask");  // HTMLInputElement | null

function toggle(id: number) {
  const t = tasks.value.find((t) => t.id === id);
  if (t) t.status = t.status === "done" ? "todo" : "done";
}
function add() {
  if (!draft.title.trim()) return;
  tasks.value.push({ id: Date.now(), title: draft.title.trim(), status: "todo" });
  draft.title = "";
  input.value?.focus();
}

onMounted(() => {
  input.value?.focus();
  console.log("focused input placeholder:", input.value?.placeholder);
});
</script>

<template>
  <input ref="newTask" v-model="draft.title" placeholder="New task" @keyup.enter="add" />
  <ul>
    <TaskRow v-for="t in tasks" :key="t.id" :task="t" @toggle="toggle" />
  </ul>
  <p>{{ remaining }} of {{ tasks.length }} left</p>
</template>
VueTyped props, emits and inject, plus seven mistakes vue-tsc reports
<!-- keys.ts
import type { InjectionKey, Ref } from "vue";

export type Theme = "light" | "dark";
export const ThemeKey: InjectionKey<Ref<Theme>> = Symbol("theme");
-->

<!-- QtyStepper.vue: typed props (destructured with a default), typed emits, typed inject -->
<script setup lang="ts">
import { computed, inject } from "vue";
import { ThemeKey } from "./keys";

// Type-based props. Destructuring keeps reactivity (Vue 3.5) and gives defaults.
const { sku, qty, max = 10 } = defineProps<{
  sku: string;
  qty: number;
  max?: number;
}>();

// Type-based emits, named-tuple syntax (Vue 3.3+).
const emit = defineEmits<{
  change: [sku: string, qty: number];
  limit: [];
}>();

const theme = inject(ThemeKey);           // Ref<Theme> | undefined
const atMax = computed(() => qty >= max); // ComputedRef<boolean>

function inc() {
  if (atMax.value) return emit("limit");
  emit("change", sku, qty + 1);
}
defineExpose({ inc });
</script>

<template>
  <button :class="theme" @click="inc">{{ sku }}: {{ qty }} / {{ max }}</button>
</template>

<!-- Mistakes.vue: vue-tsc reports one error for each numbered comment -->
<script setup lang="ts">
import { ref, provide, inject, useTemplateRef } from "vue";
import QtyStepper from "./QtyStepper.vue";
import { ThemeKey } from "./keys";

const qty = ref(1);
qty.value = "2";                                   // 1: wrong type into a Ref<number>
provide(ThemeKey, ref("blue"));                    // 2: "blue" is not a Theme
const theme = inject(ThemeKey);
theme.value = "light";                             // 3: inject() may return undefined
const stepper = useTemplateRef("stepper");
stepper.value?.reset();                            // 4: only inc() is exposed
</script>

<template>
  <!-- 5: qty must be a number -->
  <QtyStepper ref="stepper" sku="A" :qty="'3'" />
  <!-- 6: sku is required -->
  <QtyStepper :qty="3" />
  <!-- 7: the change handler receives (sku: string, qty: number) -->
  <QtyStepper sku="B" :qty="1" @change="(sku: number) => sku" />
</template>

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 41 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextMedium

useId() vs getCurrentInstance() β€” how do you generate SSR-safe IDs in Vue 3.5?

Continue