Skip to the solution
HardFrontend10 min read

How do generic <script setup> components work (generic="T")?

885 views

The problem

Question presented to candidate: "We have a DataTable component used for users, orders and products. Today its rows prop is any[], so the row slot and the select event lose all typing. How would you make it generic in <script setup>, and what does that actually change at runtime?"

What a strong answer should cover:

  • <script setup lang="ts" generic="T"> declares a type parameter for the whole component, with constraints and multiple parameters allowed (generic="T extends { id: string }, K extends keyof T").
  • T can be used in defineProps, defineEmits, defineModel, defineSlots and local code, and is inferred from the props the parent passes.
  • The parent then gets typed slot props, event payloads and v-model with no annotations.
  • It is type-only: the compiled component has runtime props such as { type: Array } and no knowledge of T, so checking happens in the IDE and vue-tsc, not in the browser.
  • Constraint violations and wrong usage surface as real vue-tsc errors; plain tsc and vite build do not check templates.
  • Introduced in Vue 3.3; the non-SFC equivalent is a generic defineComponent setup function.

Clarifying questions expected:

  • "Do rows always share a key field? That becomes the generic constraint."
  • "Is vue-tsc part of CI? Without it the generic only helps in the editor."

Code / implementation expected: Yes β€” a generic list with a typed slot and event, and a parent that triggers at least one type error.

  • generics
  • typescript
  • script-setup

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

10 min read Β· 11 sections

Read the code

VueA generic list component and a parent with three type errors vue-tsc catches
<!-- GenericList.vue -->
<script setup lang="ts" generic="T extends { id: string | number }">
// T is inferred from whatever array the parent passes in :items.
const props = defineProps<{ items: T[]; label: (item: T) => string }>();
const selected = defineModel<T["id"] | null>({ default: null });
const emit = defineEmits<{ pick: [item: T] }>();
defineSlots<{ row?(props: { item: T; active: boolean }): any }>();

function choose(item: T) {
  selected.value = item.id;
  emit("pick", item);
}
</script>

<template>
  <ul>
    <li v-for="item in props.items" :key="item.id" @click="choose(item)">
      <slot name="row" :item="item" :active="item.id === selected">{{ label(item) }}</slot>
    </li>
  </ul>
</template>

<!-- Users.vue: vue-tsc reports the three marked errors -->
<script setup lang="ts">
import { ref } from "vue";
import GenericList from "./GenericList.vue";

type User = { id: number; name: string; email: string };
const users: User[] = [{ id: 1, name: "Ada", email: "ada@example.com" }];
const selectedId = ref<number | null>(null);
const wrongModel = ref("1");

function onPick(u: User) {
  console.log(u.email);
}
</script>

<template>
  <!-- OK: T is inferred as User everywhere below -->
  <GenericList :items="users" :label="(u) => u.name" v-model="selectedId" @pick="onPick">
    <template #row="{ item, active }">{{ active ? ">" : "" }} {{ item.email }}</template>
  </GenericList>

  <!-- Error 1: the slot prop is a User, and User has no phone -->
  <GenericList :items="users" :label="(u) => u.name">
    <template #row="{ item }">{{ item.phone }}</template>
  </GenericList>

  <!-- Error 2: v-model must be T["id"], which is number for User -->
  <GenericList :items="users" :label="(u) => u.name" v-model="wrongModel" />

  <!-- Error 3: the items do not satisfy the constraint (no id) -->
  <GenericList :items="[{ name: 'no id' }]" :label="(x) => x.name" />
</template>
VueThe same idea without a second file: a generic defineComponent setup function
<script setup lang="ts">
import { defineComponent, h, ref, type VNodeChild } from "vue";

// The non-SFC twin of <script setup generic="T">: defineComponent with a
// generic setup function. Runtime props still have to be listed (types are erased).
const PickList = defineComponent(
  <T extends { id: string | number }>(
    props: { items: T[]; label: (item: T) => string },
    { emit, slots }: {
      emit: (e: "pick", item: T) => void;
      slots: { row?: (p: { item: T }) => VNodeChild };
    },
  ) => {
    return () =>
      h("ul", props.items.map((item) =>
        h("li", { key: item.id, onClick: () => emit("pick", item) },
          slots.row ? [slots.row({ item })] : props.label(item)),
      ));
  },
  { props: ["items", "label"], emits: ["pick"] },
);

type Book = { id: string; title: string; year: number };
type City = { id: number; name: string; population: number };

const books: Book[] = [
  { id: "b1", title: "Dune", year: 1965 },
  { id: "b2", title: "Neuromancer", year: 1984 },
];
const cities: City[] = [{ id: 7, name: "Lagos", population: 15_400_000 }];
const last = ref("nothing yet");

// Inside each handler the argument is already typed: Book here, City below.
const onBook = (b: Book) => (last.value = `book ${b.title} (${b.year})`);
const onCity = (c: City) => (last.value = `city ${c.name}, pop ${c.population}`);
</script>

<template>
  <PickList :items="books" :label="(b) => b.title" @pick="onBook" />
  <PickList :items="cities" :label="(c) => c.name" @pick="onCity">
    <template #row="{ item }"><b>{{ item.name }}</b> ({{ item.population.toLocaleString("en") }})</template>
  </PickList>
  <p>picked: {{ last }}</p>
</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 75 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 nextHard

When should you choose Vapor over VDOM in a real app β€” trade-offs?

Continue