| |

TypeScript 89 🔷 TypeScript with Vue

Vue 3 was rewritten in TypeScript, and its Composition API was designed with type inference as a first-class concern. The result is a framework where you rarely need to write explicit type annotations for reactive state, props, or computed values — the compiler infers them from context, and the editor autocompletes them without ceremony .

The Vue documentation states it plainly: “Vue provides excellent TypeScript support in Composition API” . The <script setup> macro system — defineProps, defineEmits, defineExpose — replaces the Options API’s runtime object syntax with type-based declarations that the compiler can understand. In most cases, you write TypeScript, not JavaScript with type comments.

Key point: Vue’s TypeScript integration works in layers. The template compiler generates type information from your template expressions. The <script setup> macros declare props, emits, and exposed methods with type arguments. The reactivity system infers ref and computed types from their initial values. You only add explicit types when the compiler cannot infer what you intend — nullable refs, complex object shapes, and template refs to DOM elements .


Why TypeScript and Vue work well together

Vue 3’s Composition API was designed alongside its TypeScript definitions. The reactivity system — ref(), reactive(), computed() — infers types from the values you pass, and the template compiler cross-checks those types against the template’s usage.

The template type checking problem. A Vue template uses {{ count }} and count.value is a number. The compiler knows this. If you write {{ count.toUpperCase() }}, TypeScript reports the error at compile time, not in the browser . This is the biggest win of Vue’s TypeScript integration: the template is type-checked against the script’s types, and mistakes that would otherwise surface as runtime errors are caught before the build.

The props contract problem. A component declares its props with defineProps<Props>() where Props is an interface. The parent’s template is checked against that interface. If the parent passes a string where the child expects a number, the compiler reports it . This works in both directions: the child’s TypeScript signature and the parent’s template usage are connected.

The emits problem. defineEmits<Emits>() declares the events a component can emit and the types of their arguments. The compiler checks every emit() call inside the component and every @event binding in the parent’s template . An event with the wrong argument type is a compile error, not a runtime warning.

The reactive unwrapping problem. Vue’s reactivity system unwraps refs in templates automatically. {{ count }} works whether count is a number or a Ref<number>. TypeScript understands this unwrapping in the template context but requires .value in the script. The compiler knows the difference between the two contexts, so count.value is required in <script setup> and count is correct in the template .

The trade-off. Vue’s TypeScript support is excellent for Composition API but less automatic for Options API. In Options API, defineComponent() is needed to get proper type inference for this inside data, computed, and methods . The Composition API with <script setup> is the recommended path for new TypeScript projects.


a. Typing Props, Emits, and Exposed Methods

The <script setup> macros declare the component’s public interface. defineProps declares inputs, defineEmits declares outputs, and defineExpose declares what the parent can access via a template ref.

Typing props with a type-based declaration uses an interface:

<script setup lang="ts">
interface Props {
  title: string;
  count?: number;
  items: string[];
  user: { name: string; age: number };
}

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

The compiler infers the runtime prop declarations from the TypeScript interface. title is a required string, count is an optional number, items is a required string array, and user is a required object with a specific shape . The parent’s template is checked against this interface.

For default values, Vue 3.5+ supports reactive props destructuring:

<script setup lang="ts">
interface Props {
  msg?: string;
  labels?: string[];
}

const { msg = 'hello', labels = ['one', 'two'] } = defineProps<Props>();
</script>

The defaults are applied when the parent does not provide a value. For Vue 3.4 and below, withDefaults is the alternative .

Typing emits with the concise tuple syntax (Vue 3.3+):

<script setup lang="ts">
const emit = defineEmits<{
  change: [id: number];
  update: [value: string];
}>();

function handleUpdate(value: string) {
  emit('update', value); // Type-safe
  // emit('update', 123); // Error: number not assignable to string
}
</script>

The key is the event name, and the value is a tuple of the argument types. The compiler checks every emit call against this declaration . When the parent uses @update="handler", the handler’s parameter type is inferred from the emit declaration.

Typing exposed methods with defineExpose gives the parent a typed reference to the child component:

<script setup lang="ts">
import { ref } from 'vue';

const count = ref(0);

function increment() {
  count.value++;
}

defineExpose({ increment, count });
</script>

The parent accesses these via useTemplateRef or a template ref typed with InstanceType<typeof Child>:

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import ChildComponent from './ChildComponent.vue';

const childRef = ref<InstanceType<typeof ChildComponent> | null>(null);

onMounted(() => {
  childRef.value?.increment(); // Typed and safe
});
</script>

The InstanceType<typeof ChildComponent> type extracts the component instance type, including anything exposed via defineExpose .


b. Reactivity: ref, reactive, computed

The reactivity system infers types from initial values. Explicit type arguments are only needed when the inferred type is too wide or when the value starts as null.

ref() infers from the initial value:

import { ref } from 'vue';

const count = ref(0);           // Ref<number>
const message = ref('hello');   // Ref<string>
const isActive = ref(true);     // Ref<boolean>

The explicit type argument is needed for nullable refs and empty collections:

const selectedId = ref<number | null>(null);  // Without <number | null>, inferred as Ref<null>
const users = ref<User[]>([]);                // Without <User[]>, inferred as Ref<never[]>

The Vue documentation confirms: “If you specify a generic type argument but omit the initial value, the resulting type will be a union type that includes undefined” . ref<number>() produces Ref<number | undefined>.

reactive() infers from its argument object:

import { reactive } from 'vue';

const book = reactive({ title: 'Vue 3 Guide' });  // { title: string }

The Vue documentation warns against using the generic argument of reactive() because “the returned type, which handles nested ref unwrapping, is different from the generic argument type” . Use an interface annotation instead:

interface Book {
  title: string;
  author: string;
}

const book: Book = reactive({ title: 'Vue 3 Guide', author: 'Evan' });

One critical mistake to avoid: reactive() does not work with primitives. reactive(0) is a TypeScript error. Use ref() for primitives and reactive() for objects .

computed() infers from the getter’s return value:

import { ref, computed } from 'vue';

const count = ref(0);
const double = computed(() => count.value * 2);  // ComputedRef<number>

An explicit type argument is useful when the getter’s return type is ambiguous or when you want to assert the computed value’s shape:

const double = computed<number>(() => {
  // type error if this doesn't return a number
  return count.value * 2;
});

Writable computed properties use a getter and setter:

const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value: string) => {
    const [first, last] = value.split(' ');
    firstName.value = first;
    lastName.value = last;
  },
});

The setter’s parameter type is inferred from the getter’s return type .


c. provide/inject, Generic Components, and Event Handlers

provide/inject is Vue’s dependency injection system. The InjectionKey type synchronizes the type between provide() and inject():

// keys.ts
import type { InjectionKey, Ref } from 'vue';

export interface ThemeContext {
  theme: Ref<'light' | 'dark'>;
  toggleTheme: () => void;
}

export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme');

The providing component uses the key:

<script setup lang="ts">
import { provide, ref } from 'vue';
import { ThemeKey, type ThemeContext } from './keys';

const theme = ref<'light' | 'dark'>('light');
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light';
};

provide(ThemeKey, { theme, toggleTheme });
</script>

The injecting component gets the correct type automatically:

<script setup lang="ts">
import { inject } from 'vue';
import { ThemeKey } from './keys';

const { theme, toggleTheme } = inject(ThemeKey)!;
// theme: Ref<'light' | 'dark'>
// toggleTheme: () => void
</script>

Without the !, the type is ThemeContext | undefined because inject() returns undefined when no provider is found. The recommended pattern is a composable that throws if the context is missing :

export function useTheme(): ThemeContext {
  const context = inject(ThemeKey);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

Generic components use the generic attribute on <script setup>:

<script setup lang="ts" generic="T">
interface Props {
  items: T[];
  selected: T;
}

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

The T type parameter flows through props, emits, and the template. This is how you build components that work with any data type while preserving type safety . One current limitation: generic components do not work well with runtime props declarations. The manual runtime props array is still needed for the template to work, but it loses type inference for the props themselves .

Event handlers with native DOM events need explicit types when strict is enabled:

<script setup lang="ts">
function handleChange(event: Event) {
  const target = event.target as HTMLInputElement;
  console.log(target.value);
}
</script>

The Vue documentation notes that “without type annotation, the event argument will implicitly have a type of any” and that this “will also result in a TS error if "strict": true or "noImplicitAny": true are used” . A cleaner approach is a type guard:

function handleInput(event: Event) {
  if (event.target instanceof HTMLInputElement) {
    const value = event.target.value; // Safe, typed as string
  }
}

Complete Example Session

This session builds a typed form component, a generic list component, a typed provide/inject context, and a composable.

// ============================================
// PART 1: THE TYPED PROPS INTERFACE
// ============================================

<!-- UserCard.vue -->
<script setup lang="ts">
interface Props {
  user: {
    id: number;
    name: string;
    role: 'admin' | 'user';
  };
  showRole?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  showRole: false,
});
</script>

<template>
  <div class="user-card">
    <h3>{{ user.name }}</h3>
    <span v-if="showRole">{{ user.role }}</span>
  </div>
</template>

// ============================================
// PART 2: THE TYPED EMITS
// ============================================

<script setup lang="ts">
interface Props {
  items: string[];
}

const props = defineProps<Props>();

const emit = defineEmits<{
  select: [item: string, index: number];
  clear: [];
}>();

function handleSelect(item: string, index: number) {
  emit('select', item, index);
}

function handleClear() {
  emit('clear');
}
</script>

// ============================================
// PART 3: THE TYPED REF
// ============================================

<script setup lang="ts">
import { ref, computed } from 'vue';

interface User {
  id: number;
  name: string;
  email: string;
}

const user = ref<User | null>(null);
const users = ref<User[]>([]);
const count = ref(0);

const isLoggedIn = computed(() => user.value !== null);

async function loadUsers() {
  const response = await fetch('/api/users');
  users.value = await response.json();
}
</script>

// ============================================
// PART 4: THE TYPED REACTIVE
// ============================================

<script setup lang="ts">
import { reactive } from 'vue';

interface FormState {
  email: string;
  password: string;
  remember: boolean;
}

const form = reactive<FormState>({
  email: '',
  password: '',
  remember: false,
});

function reset() {
  form.email = '';
  form.password = '';
  form.remember = false;
}
</script>

// ============================================
// PART 5: THE TYPED COMPUTED
// ============================================

<script setup lang="ts">
import { ref, computed } from 'vue';

const firstName = ref('John');
const lastName = ref('Doe');

const fullName = computed<string>(() => {
  return `${firstName.value} ${lastName.value}`;
});

const fullNameWritable = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value: string) => {
    const [first, last] = value.split(' ');
    firstName.value = first;
    lastName.value = last;
  },
});
</script>

// ============================================
// PART 6: THE TYPED PROVIDE/INJECT
// ============================================

// keys.ts
import type { InjectionKey, Ref } from 'vue';

export interface AuthContext {
  user: Ref<{ id: number; name: string } | null>;
  isAuthenticated: Ref<boolean>;
  login: (username: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
}

export const AuthKey: InjectionKey<AuthContext> = Symbol('auth');

// AuthProvider.vue
<script setup lang="ts">
import { provide, ref, computed } from 'vue';
import { AuthKey, type AuthContext } from './keys';

const user = ref<{ id: number; name: string } | null>(null);

const isAuthenticated = computed(() => user.value !== null);

async function login(username: string, password: string) {
  const response = await fetch('/api/login', {
    method: 'POST',
    body: JSON.stringify({ username, password }),
  });
  user.value = await response.json();
}

async function logout() {
  user.value = null;
}

provide(AuthKey, { user, isAuthenticated, login, logout });
</script>

// useAuth.ts (composable)
import { inject } from 'vue';
import { AuthKey, type AuthContext } from './keys';

export function useAuth(): AuthContext {
  const context = inject(AuthKey);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

// ============================================
// PART 7: THE GENERIC COMPONENT
// ============================================

<!-- GenericList.vue -->
<script setup lang="ts" generic="T">
interface Props {
  items: T[];
  selected?: T;
}

const props = defineProps<Props>();

const emit = defineEmits<{
  select: [item: T];
}>();
</script>

<template>
  <ul>
    <li
      v-for="(item, index) in items"
      :key="index"
      :class="{ selected: item === selected }"
      @click="emit('select', item)"
    >
      <slot :item="item" :index="index" />
    </li>
  </ul>
</template>

// ============================================
// PART 8: THE TEMPLATE REF TO A DOM ELEMENT
// ============================================

<script setup lang="ts">
import { ref, onMounted } from 'vue';

const inputEl = ref<HTMLInputElement | null>(null);

onMounted(() => {
  inputEl.value?.focus();
});
</script>

<template>
  <input ref="inputEl" type="text" />
</template>

// ============================================
// PART 9: THE TEMPLATE REF TO A COMPONENT
// ============================================

<!-- ChildDialog.vue -->
<script setup lang="ts">
import { ref } from 'vue';

const isOpen = ref(false);

function open() {
  isOpen.value = true;
}

function close() {
  isOpen.value = false;
}

defineExpose({ open, close });
</script>

// Parent.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import ChildDialog from './ChildDialog.vue';

const dialogRef = ref<InstanceType<typeof ChildDialog> | null>(null);

onMounted(() => {
  dialogRef.value?.open(); // Typed and safe
});
</script>

<template>
  <ChildDialog ref="dialogRef" />
</template>

// ============================================
// PART 10: THE STRICT TSCONFIG
// ============================================

// tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "jsx": "preserve",
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "types": ["vite/client"]
  },
  "vueCompilerOptions": {
    "target": 3
  }
}

The ten parts cover typed props, typed emits, typed ref, typed reactive, typed computed, typed provide/inject, a generic component, a template ref to a DOM element, a template ref to a component, and the strict tsconfig.json.


Quick Reference

The <script setup> Macros

MacroPurposeType Argument
defineProps<T>()Declare propsInterface or type literal
defineEmits<T>()Declare emitsCall signatures or tuple syntax
defineExpose()Expose methodsObject literal
withDefaults()Props defaults (≤3.4)Two arguments

The Reactivity Types

FunctionInferred TypeExplicit Type Needed
ref(0)Ref<number>No
ref(null)Ref<null>Yes: ref<T | null>(null)
ref([])Ref<never[]>Yes: ref<T[]>([])
reactive({})Object shapeNo
computed(() => ...)ComputedRef<T>Only for assertion

The Injection Types

TypePurpose
InjectionKey<T>Symbol key with type sync
inject(Key)Returns T | undefined
inject(Key, default)Returns T
inject(Key, factory, true)Factory default

The Generic Component Syntax

ElementSyntax
Generic declaration<script setup lang="ts" generic="T">
Generic in propsdefineProps<{ items: T[] }>()
Generic in emitsdefineEmits<{ select: [item: T] }>()
Constraintgeneric="T extends string | number"

The Template Ref Types

Ref TargetType
DOM elementref<HTMLInputElement | null>(null)
Componentref<InstanceType<typeof Child> | null>(null)
Element arrayref<HTMLElement[]>([])

Best Practices

✅ Do This:

// Use type-based defineProps
const props = defineProps<Props>();                           // ✅
// Use the concise tuple syntax for emits (3.3+)
const emit = defineEmits<{ change: [id: number] }>();         // ✅
// Add explicit type for nullable refs
const user = ref<User | null>(null);                          // ✅
// Use InjectionKey for provide/inject
const Key: InjectionKey<Context> = Symbol('key');             // ✅
// Type template refs with InstanceType
const child = ref<InstanceType<typeof Child> | null>(null);   // ✅
// Use type guard for DOM events
if (event.target instanceof HTMLInputElement) { ... }         // ✅

❌ Don’t Do This:

// Don't use reactive with primitives
const count = reactive(0);                                    // ❌ error
// Don't destructure reactive without toRefs
const { count } = reactive({ count: 0 });                     // ❌ loses reactivity
// Don't forget .value in script
count++;  // count is a ref                                        // ❌
// Don't use any for event handlers
function handle(event: any) { ... }                           // ❌
// Don't mix runtime and type declarations
defineProps<Props>(); defineProps({ title: String });         // ❌

Common Pitfalls

PitfallWhy It HappensFix
Reactive primitive errorreactive() only works on objectsUse ref() for primitives
Destructured reactive loses reactivityJavaScript destructuring breaks the proxyUse toRefs()
Missing .value in scriptRefs require .value in script, not templateAdd .value in <script setup>
event.target.value errortarget is EventTargetUse type guard or cast to HTMLInputElement
Generic component loses props typingRuntime props needed for genericsDeclare props: ['msg', 'list'] manually
inject returns undefinedNo provider foundProvide a default or throw in composable

Real-World Examples

1. Typed Props Interface

const props = defineProps<{ title: string; count?: number }>();

2. Typed Emits Tuple Syntax

const emit = defineEmits<{ change: [id: number] }>();

3. Nullable Ref

const user = ref<User | null>(null);

4. Typed Reactive with Interface

const form: FormState = reactive({ email: '', password: '' });

5. Typed Computed

const double = computed<number>(() => count.value * 2);

6. InjectionKey

export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme');

7. Composable with Type Guard

export function useTheme(): ThemeContext {
  const ctx = inject(ThemeKey);
  if (!ctx) throw new Error('Missing provider');
  return ctx;
}

8. Generic Component

<script setup lang="ts" generic="T">
defineProps<{ items: T[] }>();
</script>

9. Template Ref to DOM

const inputEl = ref<HTMLInputElement | null>(null);

10. Template Ref to Component

const dialog = ref<InstanceType<typeof ChildDialog> | null>(null);

Visual

The <script setup> Macro Flow

┌──────────────────────────────────────────────┐
│  <script setup> MACROS                       │
│                                              │
│  defineProps<Props>()                        │
│    └─ Compiler generates runtime props       │
│    └─ Parent template is checked             │
│                                              │
│  defineEmits<Emits>()                        │
│    └─ Compiler generates emits option        │
│    └─ emit() calls are checked               │
│                                              │
│  defineExpose({ method })                    │
│    └─ Parent ref gets typed instance         │
│                                              │
│  The macros are compiler-supported.          │
│  They do not exist at runtime.               │
│                                              │
└──────────────────────────────────────────────┘

The ref Inference

┌──────────────────────────────────────────────┐
│  REF INFERENCE                               │
│                                              │
│  ref(0)          → Ref<number>               │
│  ref('hello')    → Ref<string>               │
│  ref(true)       → Ref<boolean>              │
│  ref([])         → Ref<never[]>              │
│  ref(null)       → Ref<null>                 │
│                                              │
│  Explicit needed for:                        │
│  ref<User[]>([])                             │
│  ref<User | null>(null)                      │
│  ref<number>()  → Ref<number | undefined>    │
│                                              │
└──────────────────────────────────────────────┘

The InjectionKey Flow

┌──────────────────────────────────────────────┐
│  INJECTIONKEY                                │
│                                              │
│  keys.ts:                                    │
│  const Key: InjectionKey<Context> =          │
│    Symbol('key')                             │
│                                              │
│  Provider:                                   │
│  provide(Key, context)                       │
│                                              │
│  Consumer:                                   │
│  const ctx = inject(Key)                     │
│    └─ Type: Context | undefined              │
│                                              │
│  Type is synchronized across files.          │
│                                              │
└──────────────────────────────────────────────┘

The Generic Component

┌──────────────────────────────────────────────┐
│  GENERIC COMPONENT                           │
│                                              │
│  <script setup lang="ts" generic="T">        │
│    defineProps<{ items: T[] }>()             │
│    defineEmits<{ select: [item: T] }>()      │
│  </script>                                   │
│                                              │
│  Parent:                                     │
│  <GenericList :items="users"                 │
│    @select="user => ...">                    │
│    └─ user is inferred as User               │
│                                              │
│  T flows from parent to child.               │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Props declarationdefineProps<Props>()
Emits declarationdefineEmits<Emits>()
Expose declarationdefineExpose({ method })
Ref inferenceAutomatic from initial value
Nullable refref<T | null>(null)
Reactivereactive({ ... }) or : Interface
Computedcomputed(() => ...)
Injection keyInjectionKey<T> = Symbol('name')
Generic components<script setup generic="T">
Template ref (DOM)ref<HTMLInputElement | null>(null)
Template ref (component)ref<InstanceType<typeof Child> | null>(null)

Key takeaways:

  • Vue 3’s Composition API was designed with TypeScript inference in mind. ref(), reactive(), and computed() infer types from their initial values. Explicit type arguments are only needed for nullable refs, empty collections, and DOM element refs .
  • The <script setup> macros replace runtime declarations with type-based ones. defineProps<Props>(), defineEmits<Emits>(), and defineExpose() generate the runtime options from TypeScript types. The compiler checks the template against the script’s types in both directions .
  • The concise tuple syntax for emits is the modern form (3.3+). defineEmits<{ change: [id: number] }>() is shorter and clearer than the call-signature form. The compiler checks every emit() call against the declaration .
  • InjectionKey<T> synchronizes types between provider and consumer. The symbol key carries the type, so inject(Key) returns T | undefined without explicit annotation. The composable pattern wraps the undefined check and throws when the provider is missing .
  • Generic components use the generic attribute. <script setup generic="T"> declares a type parameter that flows through props, emits, and the template. The parent’s type is inferred and checked against the child’s generic signature .
  • Template refs need explicit types. DOM element refs use ref<HTMLInputElement | null>(null). Component refs use ref<InstanceType<typeof Child> | null>(null) to access exposed methods with full type safety .
  • The strict TypeScript options catch the mistakes that matter in Vue. strict: true, noImplicitAny: true, and strictNullChecks: true ensure that untyped event handlers, nullable state, and missing props are caught at compile time rather than surfacing in the browser .

Remember: Vue and TypeScript work together because Vue’s reactivity system produces types that the template compiler can verify. Write <script setup lang="ts">, declare your props with an interface, your emits with tuple syntax, and your refs with explicit types only when inference is insufficient. Use InjectionKey for provide/inject, InstanceType for component refs, and type guards for DOM events. The compiler checks both the script and the template, and the editor autocompletes everything from the same source of truth. Vue 3 is not JavaScript with type comments — it is a framework that was designed for TypeScript from the start.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!