Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

TypeScript with Vue

Typing props, emits, refs and composables with <script setup lang="ts">.

TypeScript adds types to JavaScript. In a Vue project that means your editor knows what props a component takes, what an API response looks like and what a composable returns, and it underlines mistakes before you run the code. Vue 3 is written in TypeScript, and <script setup lang="ts"> gives you full type checking in components with very little extra syntax.

TypeScript runs in a build step: Vite strips the types and vue-tsc checks them. The code in this lesson is illustrative TypeScript for a Vite project. This site's demos run plain JavaScript from a CDN, so the live demo shows the one part of typing that does need runtime code: checking data that arrives from an API.

Setting up

The official scaffolding tool asks whether you want TypeScript:

npm create vue@latest my-app   # answer "Yes" to TypeScript
cd my-app && npm install

In an existing Laravel + Vite project, install the type checker and add a tsconfig.json:

npm install -D typescript vue-tsc
// package.json
{
    "scripts": {
        "build": "vue-tsc --noEmit && vite build",
        "type-check": "vue-tsc --noEmit"
    }
}

Vite itself doesn't type-check: it only removes the types, which keeps the dev server fast. vue-tsc --noEmit does the checking, in your editor (via the Vue - Official VS Code extension), in CI and before each build.

Typing props

Pass an interface to defineProps as a type argument. Vue generates the runtime prop declarations from it:

<script setup lang="ts">
interface Task {
    id: number;
    title: string;
    priority: 'low' | 'medium' | 'high';
    due_on: string | null;
}

const props = defineProps<{
    task: Task;
    compact?: boolean;        // ? makes it optional
}>();

props.task.priority;          // type: 'low' | 'medium' | 'high'
props.task.prioirty;          // ❌ error: Property 'prioirty' does not exist
</script>

Default values for optional props use withDefaults:

const props = withDefaults(defineProps<{ compact?: boolean; pageSize?: number }>(), {
    compact: false,
    pageSize: 10,
});

Vue 3.5+: destructured props are reactive, so defaults can be plain JavaScript defaults: const { compact = false, pageSize = 10 } = defineProps<{ … }>(). This site's demos run Vue 3.4, so withDefaults is shown first. You'll see both in real codebases.

Now the parent gets checked too:

<TaskRow :task="task" />                 <!-- ✅ -->
<TaskRow :task="{ id: '7' }" />          <!-- ❌ id: string is not assignable to number; title is missing -->

Typing emits

List each event and its payload, so both the emit() call and the parent's handler are checked:

const emit = defineEmits<{
    toggle: [id: number];
    rename: [id: number, title: string];
}>();

emit('toggle', task.id);        // ✅
emit('toggle', 'seven');        // ❌ string is not assignable to number
emit('delete', task.id);        // ❌ "delete" is not a declared event

Typing ref, reactive and computed

Most of the time TypeScript infers the type from the initial value, so you write nothing extra:

const count = ref(0);                      // Ref<number>
const search = ref('');                    // Ref<string>
const open = computed(() => count.value > 0);   // ComputedRef<boolean>

Add a type argument when the initial value doesn't tell the whole story: an empty array, or something that starts as null:

const tasks = ref<Task[]>([]);             // otherwise inferred as never[]
const selected = ref<Task | null>(null);   // starts empty, holds a Task later

selected.value?.title;                     // ?. because it might be null

Template refs (references to DOM elements) work the same way:

const input = ref<HTMLInputElement | null>(null);
onMounted(() => input.value?.focus());

// Vue 3.5+: useTemplateRef infers the element type from the template
const input = useTemplateRef<HTMLInputElement>('search');

Typing API responses

TypeScript can't see what your Laravel API actually sends, so describe it once in a shared file and use it everywhere. This interface matches this site's real /api/demo/tasks endpoint (built from TaskResource in the Building APIs lesson):

// types/api.ts
export interface Task {
    id: number;
    title: string;
    description: string | null;
    priority: 'low' | 'medium' | 'high';
    priority_label: string;
    due_on: string | null;          // "2026-10-23"
    completed: boolean;
    completed_at: string | null;
    assignee: { id: number; name: string } | null;
    project?: { id: number; name: string; slug: string };
    tags?: { name: string; slug: string }[];
}

export interface Paginated<T> {
    data: T[];
    links: { first: string; last: string; prev: string | null; next: string | null };
    meta: { current_page: number; last_page: number; per_page: number; total: number };
}
async function loadTasks(): Promise<Paginated<Task>> {
    const res = await fetch('/api/demo/tasks');
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();     // ⚠️ TypeScript trusts this, it doesn't check it
}

That return res.json() is a promise, not a check. If the backend renames a field or sends "7" instead of 7, TypeScript won't notice, because types are erased at runtime. For data you don't control, add a type guard, a function that checks the shape and tells TypeScript the result:

const PRIORITIES = ['low', 'medium', 'high'] as const;

function isTask(value: unknown): value is Task {
    return typeof value === 'object' && value !== null
        && typeof (value as Task).id === 'number'
        && typeof (value as Task).title === 'string'
        && PRIORITIES.includes((value as Task).priority)
        && typeof (value as Task).completed === 'boolean';
}

const body: unknown = await res.json();
const tasks = (body as { data: unknown[] }).data.filter(isTask);   // Task[]

Here is that guard (compiled to JavaScript) running against this site's real API. Switch to the broken response to see what a type guard catches that the type annotation alone never would:

For larger apps, a schema library such as Zod declares the shape once and gives you both the runtime check and the TypeScript type: type Task = z.infer<typeof TaskSchema>.

Typed composables

Generic type parameters let one composable return correctly typed data for any endpoint:

// composables/useFetch.ts
import { ref, shallowRef, type Ref } from 'vue';

export function useFetch<T>(url: string) {
    const data: Ref<T | null> = shallowRef(null);
    const error = ref<string | null>(null);
    const loading = ref(true);

    fetch(url, { headers: { Accept: 'application/json' } })
        .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
        .then((json: T) => (data.value = json))
        .catch((e: Error) => (error.value = e.message))
        .finally(() => (loading.value = false));

    return { data, error, loading };
}
const { data } = useFetch<Paginated<Task>>('/api/demo/tasks');

data.value?.data[0].title;          // string ✅
data.value?.meta.total;             // number ✅
data.value?.data[0].name;           // ❌ Property 'name' does not exist on type 'Task'

Typing provide / inject

inject() can't know what an ancestor provided, so it returns T | undefined. An InjectionKey links the two:

// keys.ts
import type { InjectionKey, Ref } from 'vue';
export const CurrentUserKey: InjectionKey<Ref<{ id: number; name: string }>> = Symbol('currentUser');

// App.vue
provide(CurrentUserKey, user);

// Deeply nested child
const user = inject(CurrentUserKey);   // Ref<{ id: number; name: string }> | undefined

Generic components

Vue 3.3+ lets a component be generic, so a reusable list or select keeps its items' types:

<!-- DataList.vue -->
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{ items: T[] }>();
defineEmits<{ select: [item: T] }>();
</script>

<template>
    <ul>
        <li v-for="item in items" :key="item.id" @click="$emit('select', item)">
            <slot :item="item" />
        </li>
    </ul>
</template>
<DataList :items="tasks" @select="(task) => open(task.title)">   <!-- task: Task -->
    <template #default="{ item }">{{ item.priority }}</template>       <!-- item: Task -->
</DataList>

Types disappear at runtime

TypeScript's annotations are removed before the code runs, so what reaches the browser is plain JavaScript like this. The comment shows the TypeScript version. Change price to the string '12' and run it: nothing stops you at runtime, and + silently joins strings instead of adding. Catching this kind of bug is what the type checker is for:

Key takeaways

  • Use <script setup lang="ts">. Vite strips types, and vue-tsc --noEmit checks them in CI and before builds.
  • defineProps<{ … }>() and defineEmits<{ … }>() type a component's API, and parents are checked against it.
  • ref, computed and reactive infer types. Add ref<T[]>([]) or ref<T | null>(null) when the initial value isn't enough.
  • Describe API responses with interfaces in one shared file, and remember types are erased at runtime: validate untrusted data with type guards or Zod.
  • Generic composables (useFetch<T>), InjectionKeys and generic components (generic="T") carry types through reusable code.
  • Label version-specific syntax: reactive props destructure and useTemplateRef are Vue 3.5+.