Framework typing is where most day-to-day TypeScript happens in a Nuxt codebase, and where interviewers ask concrete questions: "how does the type of $fetch('/api/x') know the response shape", "how do you type a generic table component", "where do you declare $api on useNuxtApp()". The Vue docs TypeScript page covers the Vue side chapter by chapter; this page is the working set.
defineProps<{ ... }>() with reactive destructure defaults, defineEmits<{ change: [id: string] }>(), defineSlots<{ row(props: { item: T }): any }>(), defineModel<T>(), defineExpose({ focus }). Complex runtime prop types use PropType<T>; withDefaults still exists for object-syntax props.<script setup lang="ts" generic="T extends { id: string }"> types props, slots and emits against one T, which is how a DataTable<Row> or Select<Option> keeps the row type at the call site. Template refs to generic components need ComponentExposed<typeof Comp> from vue-component-type-helpers rather than InstanceType.ref<T>() (includes undefined without an initial value), shallowRef<T>, computed<T>, reactive annotated on the variable, InjectionKey<T> for provide/inject, useTemplateRef<HTMLInputElement>('el') (3.5 infers from the matching ref attribute with the Vue extension), MaybeRefOrGetter<T> + toValue for composable inputs..nuxt/: imports.d.ts (auto-imports), components.d.ts (global component names with props), nuxt.d.ts, typed pages (with experimental.typedPages: RouteNamedMap, typed navigateTo and useRoute('users-id')), and typed server routes: the return type of each handler in server/api flows into $fetch('/api/...') and useFetch through the InternalApi interface, so changing a route's return type breaks the callers at compile time.declare module 'nuxt/schema' { interface RuntimeConfig {}, PublicRuntimeConfig {}, AppConfigInput {}, AppConfig {} } for config; declare module '#app' { interface NuxtApp { $api: ApiClient }, interface PageMeta { requiresRole?: Role }, interface RuntimeNuxtHooks {} } for injected helpers, page meta and custom hooks; declare module 'vue' { interface ComponentCustomProperties { $api: ApiClient } } for templates; declare module 'h3' { interface H3EventContext { user?: User } } for event.context on the server.app/types/*.d.ts applies to the app context, one under server/ to the server context, and shared/types to both; a .d.ts must be a module (export {}) for declare module blocks to merge rather than replace.nuxt typecheck runs vue-tsc over the generated project references; typescript.typeCheck: true does it during dev/build. Keep typescript.strict on (default) and add noUncheckedIndexedAccess via typescript.tsConfig. TypeScript 7 cannot yet check .vue templates through its API; vue-tsc on TypeScript 6 remains the checker until 7.1 lands (verify).shared/types/*.ts are auto-imported on both sides; API DTOs and schema-derived types belong there so the server route and the page agree by construction.A generic table with typed slots, and a typed injection for its context:
<script setup lang="ts" generic="Row extends { id: string | number }">
import type { InjectionKey } from 'vue'
const { rows, columns } = defineProps<{
rows: Row[]
columns: { key: keyof Row & string, label: string }[]
}>()
const emit = defineEmits<{ select: [row: Row] }>()
defineSlots<{ [K in `cell-${string}`]: (props: { row: Row, value: unknown }) => any } & { empty(): any }>()
const selected = defineModel<Row['id'] | null>('selected', { default: null })
const table = useTemplateRef<HTMLTableElement>('table')
defineExpose({ scrollToTop: () => table.value?.scrollIntoView() })
</script>
<template>
<table ref="table">
<tbody>
<tr v-for="row in rows" :key="row.id" :aria-selected="row.id === selected" @click="selected = row.id; emit('select', row)">
<td v-for="col in columns" :key="col.key">
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">{{ row[col.key] }}</slot>
</td>
</tr>
<tr v-if="!rows.length"><td :colspan="columns.length"><slot name="empty">No rows</slot></td></tr>
</tbody>
</table>
</template>
Typed server route to typed page, with no shared interface written by hand:
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')!
const product = await findProduct(id) // returns Product | null from the data layer
if (!product) throw createError({ status: 404, statusText: 'Not found' })
return { product, fetchedAt: new Date().toISOString() } // this object type flows into $fetch/useFetch callers
})
<script setup lang="ts">
const route = useRoute('products-id') // typed params with experimental.typedPages
const { data } = await useFetch(`/api/products/${route.params.id}`)
// data: Ref<{ product: Product, fetchedAt: string } | undefined> — inferred from the handler above
</script>
The augmentation file that makes injected helpers, page meta and config typed:
import type { ApiClient } from '~/utils/api'
declare module '#app' {
interface NuxtApp { $api: ApiClient }
interface PageMeta { requiresRole?: 'admin' | 'editor' }
}
declare module 'vue' {
interface ComponentCustomProperties { $api: ApiClient }
}
declare module 'nuxt/schema' {
interface RuntimeConfig { apiSecret: string }
interface PublicRuntimeConfig { apiBase: string }
interface AppConfigInput { theme?: { primary?: string } }
}
export {} // a module: the interfaces merge instead of replacing
declare module 'h3' {
interface H3EventContext { user?: { id: string, role: 'admin' | 'editor' | 'viewer' } }
}
export {}
An H3EventContext augmentation placed under app/ does nothing for server code, because the server project reference does not include app/. The generated tsconfig.*.json files define who sees what; put server declarations under server/, app declarations under app/, shared ones in shared/types.
$fetch('/api/products/' + id) loses the route type because the string is no longer a literal pattern; a template literal `/api/products/${id}` keeps it. When a route type is missing, the response is unknown, which is the right moment to validate rather than cast.
Whether vue-tsc runs on TypeScript 7 yet (the 7.0 announcement deferred the API Vue needs to 7.1), and the current experimental.typedPages default in Nuxt.
#cell-price slot's row is typed per usage and that a wrong columns.key fails to compile.nuxt typecheck; find every page the change broke.export {} and observe how NuxtApp loses its other members."Nuxt generates a type map of every handler under server/api and server/routes, keyed by path and method, from the handlers' return types. $fetch and useFetch are typed against that map, so useFetch('/api/products/1') infers data as the handler's return type without any interface written by hand, and changing the handler breaks the callers under nuxt typecheck. The path has to stay a literal or template literal for the lookup to work; when it is not, the result is unknown, and that is where I validate with a schema rather than cast. Shared DTO types and schemas live in shared/, which both projects include."
"With the generic attribute on <script setup>: generic=\"Row extends { id: string }\" makes Row available to defineProps, defineEmits, defineSlots and defineModel, so a table typed with rows: Row[] gives each scoped slot a row of the caller's type and rejects a column key that does not exist on it. The consumer writes nothing extra; inference comes from the rows prop. For a template ref to such a component I use ComponentExposed from the type helpers, because InstanceType does not work on generic components."
Nothing yet. Notes filed here show up on this page and the section overview.
Generics and inference
Generic functions and constraints, inference sites and NoInfer, conditional and distributive types, infer, mapped types with key remapping, template literal types, the utility types worth knowing, and when a generic is over-engineering.
Modules, declarations and configuration
The tsconfig options that matter in a Vite and Nuxt project, module resolution and verbatimModuleSyntax, project references, writing and shipping declaration files, ambient and module augmentation, Node's native type stripping, and what TypeScript 6 and 7 changed.