Vue 3 Component Library TypeScript Props Export: A Deceptively Simple Trap
This article is based on real debugging experience from the moongate-vue component library, documenting the full journey from an
import type { ButtonProps } from 'my-lib'error to the final solution. The problem spans four knowledge domains: the Vue SFC compiler, TypeScript module resolution, theshims-vue.d.tsmechanism, and npm package publishing structure.
Introduction: An “Impossible” Error
While building the moongate-vue component library, we defined complete Props types inside our components and the build passed:
<!-- Button.vue -->
<script setup lang="ts">
export interface ButtonProps {
label?: string
variant?: "filled" | "outline"
size?: "sm" | "md" | "lg"
}
defineProps<ButtonProps>()
</script>
And exported them normally from index.ts:
export type { ButtonProps } from "./components/Button.vue"
pnpm build was fine, and pnpm run verify:build passed for all 28 components.
But when a consumer project used it:
import type { ButtonProps } from "moongate-vue"
// ❌ Module '"moongate-vue"' has no exported member 'ButtonProps'
The build passed, so why can’t consumers get the types?
The Troubleshooting Path
Here is the complete path we walked during debugging:
Error found (consumers can't get the types)
│
▼
Check dist/index.d.ts → found from './components/Button.vue' (a .vue path)
│
▼
Why does the .vue path fail? → the shims-vue.d.ts wildcard intercepts it
│
▼
Natural fix: move Props to a .ts file → compiler-sfc reports Unresolvable
│
▼
Try vueCompilerOptions.types → tested, neither pipeline supports it
│
▼
Final solution: same-file interface inside the component + a separate .ts file (dual definitions)
Let’s break down each layer.
Layer 1: The defineProps Compiler Limitation
defineProps<T>() in Vue’s <script setup> is a compile-time macro — it isn’t runtime code; it’s parsed by @vue/compiler-sfc during compilation.
The key limitation: defineProps’s type argument must be resolvable at compile time and can only reference types defined in the same file.
<!-- ✅ Works: a type defined in the same file -->
<script setup lang="ts">
interface Props {
label?: string
}
defineProps<Props>()
</script>
<!-- ❌ Doesn't work: a type imported from an external file -->
<script setup lang="ts">
import type { Props } from "./other-file"
defineProps<Props>() // Unresolvable type reference
</script>
This limitation comes from @vue/compiler-sfc’s resolveTypeElements implementation — it uses a simplified type resolver that cannot recursively resolve complex type references across files. When a Props type references types from other modules (like Component, Size, etc.), the resolver reports Unresolvable type reference.
This means a Props type must be defined inside the .vue file to be resolved correctly by defineProps. But if we put the Props type in the .vue file, index.ts has to re-export from the .vue file — which leads to the next layer.
Layer 2: The shims-vue.d.ts Wildcard Trap
Almost every Vue 3 project has a shims-vue.d.ts:
// src/shims-vue.d.ts (consumer project)
declare module "*.vue" {
import { DefineComponent } from "vue"
const component: DefineComponent<{}, {}, any>
export default component
}
This file tells TypeScript what a .vue file is — it declares that *.vue modules only export default.
When index.d.ts contains:
export type { ButtonProps } from "./components/Button.vue"
TypeScript resolving the ./components/Button.vue module will:
- If the consumer has no
shims-vue.d.ts: TS findsButton.vue.d.ts(generated by vue-tsc), which containsButtonProps→ resolves successfully ✅ - If the consumer has a
shims-vue.d.ts: TS’s module resolution prefers the wildcard declarationdeclare module '*.vue'over looking up the concrete file. The shim only declaresexport default— noButtonProps→ error ❌
This is a counter-intuitive behavior of TypeScript module resolution: wildcard declarations (*.vue) take precedence over file-path resolution. Almost every Vue 3 project needs shims-vue.d.ts, so exporting a named type from a .vue file almost always fails on the consumer side.
┌─────────────────────────────────┐
│ dist/index.d.ts │
│ export type { ButtonProps } │
│ from './components/Button.vue' │
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ shims-vue.d.ts (consumer project) │
│ declare module '*.vue' { │
│ export default component │ ← only default!
│ } │
└──────────────┬──────────────────┘
│
▼
❌ no ButtonProps
Layer 3: Why Can’t We Simply “Move It to a .ts File”
The natural solution: move the Props type to a standalone .ts file, so index.d.ts no longer references .vue.
// types/props.ts
export interface ButtonProps {
label?: string
variant?: "filled" | "outline"
size?: "sm" | "md" | "lg"
}
<!-- Button.vue -->
<script setup lang="ts">
import type { ButtonProps } from "../types/props"
defineProps<ButtonProps>()
</script>
This looks perfect — but @vue/compiler-sfc doesn’t allow it.
The compiler-sfc Type Resolution Limitation
Confirmed via a minimal reproduction test (vue-tsc 6.0.3 + compiler-sfc 3.5.35 + Vite 8):
| Scenario | compiler-sfc (Vite build) |
vue-tsc (type checking) |
|---|---|---|
| Same-file interface | ✅ passes | ✅ passes |
| Cross-file simple types (only string/number) | ✅ passes | ❌ TS2305 (note 1) |
| Cross-file complex types (referencing Component/Size) | ❌ Unresolvable | ❌ TS2305 |
Note 1: Vue 3.3+ officially claims
definePropscan reference externally imported types. In practice,compiler-sfc(the Vite build pipeline) can indeed resolve basic cross-file types. Butvue-tsc(the type-check pipeline) under atsconfig.jsonwith a"types"field walks@vue/language-core’s SFC module resolution path, which consistently rejects cross-file type imports. Since real projects almost always configure"types": ["vite/client", "node"]and friends, this difference means in practice: type checking on the consumer side always fails.
Conclusion: the T in defineProps<T>() can only reference types defined in the same file — even if compiler-sfc compiles it during the build, vue-tsc errors during type checking. For a component library, both gates must pass for it to count as usable.
Can vueCompilerOptions.types Bypass It?
Vue 3.3+ supports configuring in tsconfig.json:
{
"vueCompilerOptions": {
"types": ["/path/to/types/props.ts"],
},
}
Tested conclusion: no.
| Scenario | compiler-sfc (Vite build) |
vue-tsc (type checking) |
|---|---|---|
+ vueCompilerOptions.types |
❌ still fails | ❌ still fails |
vueCompilerOptions.types mainly affects the IDE language service (Volar/TypeScript server) layer, and does not affect the compiler-sfc compile pipeline or the vue-tsc type-check pipeline.
The Two Declaration Modes of defineProps
Given the limitation of the type-declaration mode, Vue’s defineProps actually has another mode:
| Mode | Syntax | Type resolution | Best for |
|---|---|---|---|
| Type declaration | defineProps<Props>() |
Requires compiler-sfc to resolve types |
Simple components |
| Runtime declaration | defineProps({ label: String, ... }) |
No type resolution needed | Complex types / large libraries |
The runtime declaration doesn’t go through the type-resolution pipeline, fundamentally bypassing the limitation. Large component libraries like Element Plus and Naive UI all use this mode:
// runtime declaration + ExtractPropTypes
export const buttonProps = {
label: { type: String, default: "" },
variant: { type: String, default: "filled" },
} as const
export type ButtonProps = ExtractPropTypes<typeof buttonProps>
The type is defined in a plain .ts file, index.d.ts doesn’t reference .vue, and consumers have no shim problem. This is the only solution that completely avoids type export issues.
The Solution We Finally Adopted
Given the project’s size (28 components) and the cost of change, we adopted a compromise:
Keep the Same-File Interface in the Component (for defineProps compilation)
<!-- Button.vue -->
<script setup lang="ts">
export interface ButtonProps {
label?: string
variant?: "filled" | "outline"
}
defineProps<ButtonProps>()
</script>
Define the Exported Types in a Separate .ts File
// types/props.ts
import type { Component } from "vue"
import type { Size, AddonColor } from "./components"
export interface ButtonProps {
label?: string
variant?: "filled" | "outline"
size?: Size
icon?: string | Component
}
index.ts Exports from the .ts File (No .vue References)
export type { ButtonProps } from "./types/props"
After the build, dist/index.d.ts has 0 .vue type references, so consumers resolve without issues.
The Cost: Dual Maintenance
The ButtonProps inside the component (for defineProps compilation) and the ButtonProps in types/props.ts (for external export) are two separate definitions that must be kept in sync manually.
Decision guide: for libraries under 50 components, the cost of maintaining two copies by hand is far lower than refactoring the whole project to runtime declarations (ExtractPropTypes). If the library is expected to exceed 50 components, adopt the runtime-declaration approach from the start.
Advice for Component Library Authors
Pre-Release Type Check Checklist
- Verify
index.d.tscontains no.vuetype references:
grep -c "\.vue" dist/index.d.ts # should be 0 (comments allowed)
grep "^import type.*\.vue" dist/index.d.ts # should be empty
- Simulate a consumer environment with
shims-vue.d.ts:
declare module "*.vue" {
const c: DefineComponent<{}, {}, any>
export default c
}
Verify package.json exports matches the component manifest: after adding components, it’s easy to miss an exports subpath.
Run
verify:build: check the completeness of all.jsand.d.tsartifacts.
For New Projects
If you’re building a component library from scratch, go straight to the runtime-declaration + ExtractPropTypes approach from Layer 3 — it fundamentally avoids all type export issues, and it’s the battle-tested pattern used by mainstream libraries like Element Plus and Naive UI.
Further Thinking: Automating the Dual Maintenance
The compromise solution solved “can we export”, but introduced the cost of dual maintenance. At 28 components it’s acceptable; at 100+ components, manually syncing two type definitions becomes a significant burden. Two directions are worth exploring:
Direction One: Post-Build Generation
vue-tsc generates a .vue.d.ts file for every component (containing the complete Props interface definition). Could we write a post-build script that automatically extracts the Props interfaces from these .d.ts files and generates types/props.ts?
// scripts/gen-props-types.mjs (concept example)
import { readdirSync, readFileSync, writeFileSync } from "node:fs"
// Walk dist/components/*.vue.d.ts
// Extract the export interface XxxProps { ... } blocks
// Write src/types/props.ts (or update dist/types/props.d.ts directly)
Since defineProps references the same-file type inside the component (which the compiler can resolve), and the .d.ts artifact is an accurate type snapshot, extracting from the artifact guarantees a single source of truth — the interface inside the component is authoritative, and types/props.ts is just its auto-generated mirror.
The challenge: vue-tsc’s generated .vue.d.ts is a simplified declaration (may lose JSDoc comments, internal type alias expansion, etc.), so extra processing is needed to produce consumer-friendly type files.
Direction Two: vueCompilerOptions.types
Vue 3.3+ supports configuring vueCompilerOptions.types in tsconfig.json, letting @vue/language-core add specified modules to defineProps’s type resolution context in the IDE language service.
However, as the experiments above show, this option doesn’t help either compiler-sfc (Vite build) or vue-tsc (type checking). It only works at the IDE language service level and doesn’t solve the real build problem.
Unless the Vue toolchain eventually unifies cross-file type resolution support across both pipelines, the ceiling of this direction is already clear.
The Core Contradiction
The experiments show: the Vue compile pipeline and the type-check pipeline have consistent restrictions on external type resolution — neither supports defineProps referencing cross-file imported types. This isn’t a bug in one tool; it’s a design constraint of the Vue SFC compile macro.
Summary
| Layer | Problem | Solution |
|---|---|---|
| Vue compiler | defineProps can’t resolve cross-file types (in both compiler-sfc and vue-tsc) |
Keep Props types in the same file (or use runtime declarations) |
| TS module resolution | .vue imports are intercepted by the shims wildcard |
index.d.ts must not reference .vue paths |
| Build output | Type definitions need dual maintenance | Standalone .ts file + same-file interface in the component (or runtime declarations to eliminate the dual) |
| Release process | exports whitelist omissions | Add consistency checks to verify-build |
The essence of this problem is the contract gap between two independent systems — the Vue SFC compiler and TypeScript module resolution: Vue requires types to live in the same file, while TS’s shim mechanism intercepts named exports from .vue paths. Once you understand this gap, the solution becomes clear.

