Native TypeScript 7 Support: Props, Refs, And Children In Pure TypeScript
Vue JSX 3.3 makes ordinary component inference a TypeScript feature. With jsxImportSource: "vue-jsx", TypeScript reads the JSX namespace exported by vue-jsx/jsx-runtime, and that namespace teaches the compiler how Vue components should look at JSX call sites.
The key is JSX.LibraryManagedAttributes. TypeScript calls this type whenever it checks <Comp ... />. Vue JSX uses that hook to rewrite the raw component props into the actual JSX-facing props: normal props stay normal, emitted events become onXxx, ref points at the exposed type, and JSX children are checked as Vue slots.
The Entry Point
The only default TypeScript setup is the JSX runtime:
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "vue-jsx"
}
}vue-jsx/jsx-runtime/index.d.ts exports the runtime JSX namespace and also places it in the global type space:
import type { Fragment, VNode } from 'vue'
export type { JSX } from 'vue-jsx'
declare global {
export type { JSX } from 'vue-jsx'
}
declare function jsx(type: any, props: any, key: any): VNode
export { Fragment, jsx, jsx as jsxDEV, jsx as jsxs }That is enough for TypeScript itself to ask Vue JSX three questions:
- What counts as a JSX element?
- Where should component props be read from?
- How should the raw props be rewritten for this component?
The namespace answers those questions in packages/runtime/src/jsx.ts:
export namespace JSX {
export type Element = RenderResult
export interface ElementAttributesProperty {
$props: {}
}
export interface ElementChildrenAttribute {
'v-slots': {}
}
export interface IntrinsicElements extends NativeElements {
[name: string]: any
}
export interface IntrinsicAttributes extends ReservedProps {
class?: ClassValue | undefined
style?: StyleValue | undefined
}
export type LibraryManagedAttributes<Component, Props> = // ...
}ElementAttributesProperty makes constructor-style Vue components expose their JSX props through $props. ElementChildrenAttribute says JSX children are not React-style children; they flow through v-slots. LibraryManagedAttributes then performs the real type-level adaptation.
What LibraryManagedAttributes Does
Here is the important part of the type, copied from the runtime declarations and trimmed only around unrelated DOM attributes:
export type LibraryManagedAttributes<Component, Props> = Props &
(Component extends abstract new (...args: any[]) => infer Instance
? {
ref?: NodeRef<
ExtractExposed<
Props,
'exposed' extends keyof Instance
? string extends keyof NonNullable<
NonNullable<Instance['exposed']>
>
? Instance
: UnwrapRef<Instance['exposed']>
: Instance
>
>
} & ('v-slots' extends keyof Props
? {}
: '$slots' extends keyof Instance
? SlotsToProps<Instance['$slots'] & {}>
: 'slots' extends keyof Instance
? SlotsToProps<Instance['slots'] & {}>
: {})
: Component extends (
props: Props,
ctx: {
slots: infer Slots
attrs: any
emit: infer Emit
expose: (exposed: infer Exposed extends Record<string, any>) => void
},
) => any
? {
ref?: 'ref' extends keyof Props
? Props['ref']
: NodeRef<
string extends keyof Exposed
? NativeElement | VaporComponentInstance
: UnwrapRef<Exposed>
>
} & EmitFnToProps<Emit, keyof Props> &
('v-slots' extends keyof Props ? {} : SlotsToProps<Slots & {}>)
: {
ref?: VNodeRef
})The type has three branches.
Constructor components, including components returned by Vue's
defineComponent, expose an instance type. Vue JSX reads$props,$slotsorslots, and the optionalexposedshape from that instance.Direct function components expose their information through the function signature: first parameter for props, second parameter for
slots,emit, andexpose.Unknown components fall back to Vue's regular
VNodeRef.
Props And Emits
Plain props are the easy part: after ref is removed, the original Props type remains in the attribute type. That is why literal props, generic props, unions, and required props still behave like normal TypeScript.
Emits are added only for function-style components. The helper is small:
export type EmitFnToProps<T, ExcludeKeys extends PropertyKey = ''> = T extends (
event: infer Event extends string,
...args: infer Args
) => any
? string extends Event
? {}
: {
readonly [K in Event as `on${Capitalize<K>}` extends ExcludeKeys
? never
: `on${Capitalize<K>}`]?: (...args: Args) => any
}
: {}If emit can be called as emit('change', value), the JSX call site gets an onChange prop whose callback receives the same payload. If the prop already exists, ExcludeKeys prevents the helper from generating a duplicate key.
import { type EmitFn } from 'vue'
const Counter = (
props: { value: number },
{ emit }: { emit: EmitFn<{ change: [value: number] }> },
) => <button onClick={() => emit('change', props.value + 1)} />
;<Counter
value={1}
onChange={(value) => {
value.toFixed()
}}
/>No editor plugin needs to synthesize onChange. It is produced by LibraryManagedAttributes during TypeScript's own JSX checking.
Ref Means Exposed
ref is where Vue JSX differs most from a simple VNodeRef pass-through. The public component type already knows what the component exposes; the JSX layer only has to extract it.
export type NodeRef<T> =
| ((ref: T | null, refs: Record<string, any>) => void)
| Ref
| string
declare const exposedType: unique symbol
export type ExtractExposed<
Props,
Default = never,
> = typeof exposedType extends keyof Props
? Exclude<Props[typeof exposedType], undefined>
: Default
export type ExposedToProps<T extends Record<string, any>> =
string extends keyof T
? {}
: [keyof T] extends [never]
? {}
: {
readonly [exposedType]?: T
readonly ref?: NodeRef<T>
}For constructor components, LibraryManagedAttributes looks for an exposed field on the component instance. If it is specific, ref receives UnwrapRef<Instance['exposed']>. If the exposed object is too wide, it falls back to the full instance type.
For direct function components, the ctx.expose() parameter becomes the ref target:
import { computed, type Ref } from 'vue'
const Doubler = (
props: { count: number },
{ expose }: { expose: (exposed: { double: Ref<number> }) => void },
) => {
const double = computed(() => props.count * 2)
expose({ double })
return <span>{props.count}</span>
}
;<Doubler
count={2}
ref={(exposed) => {
exposed?.double.toFixed()
}}
/>The callback sees { double: number } | null, because the type path goes through UnwrapRef.
Children Are Slots
The slot mapping is the most important part for Vue users. JSX children are syntax, but Vue children are slots. Vue JSX connects the two with ElementChildrenAttribute and SlotsToProps.
type ResolveSlots<Slots> = {
readonly [Key in keyof Slots]?: Slots[Key] extends (
...args: infer Args
) => VNode | VNode[]
? (...args: Args) => NodeChild
: Slots[Key]
}
export type SlotsToProps<
RawSlots extends SlotsType | Record<string, any> = Record<string, any>,
Slots = ResolveSlots<
RawSlots extends SlotsType
? SetupContext<EmitsOptions, RawSlots>['slots']
: RawSlots
>,
> = string extends keyof Slots
? {}
: [keyof Slots] extends [never]
? {}
: {
readonly 'v-slots'?:
| ('default' extends keyof Slots ? Slots['default'] | Slots : Slots)
| NoInfer<NodeChild>
}This helper does several small but important things:
- It accepts both Vue
SlotsTypeand plain slot records. - It keeps slot parameter types while relaxing return values to Vue JSX's
NodeChild. - It returns nothing for open-ended or empty slot objects, so untyped components do not become noisy.
- It models the default slot ergonomics: if a component has
default, the caller may pass the default slot function directly as children, or pass a slot object throughv-slots.
import { defineComponent } from 'vue-jsx'
const Panel = defineComponent(
(
props: { title: string },
{
slots,
}: {
slots: {
default?: (scope: { active: boolean }) => JSX.Element
footer?: (scope: { close: () => void }) => JSX.Element
}
},
) => {
return () => (
<section>
<h2>{props.title}</h2>
{slots.default?.({ active: true })}
</section>
)
},
{ props: ['title'] },
)
;<Panel title="Settings">
{({ active }) => <div>{active ? 'open' : 'closed'}</div>}
</Panel>
;<Panel
title="Settings"
v-slots={{
default: ({ active }) => <div>{active}</div>,
footer: ({ close }) => <button onClick={close}>Close</button>,
}}
/>active and close are inferred from the component's slot type. The caller did not install an editor plugin and did not write a generated helper file.
Why This Removes The Editor-Plugin Requirement
Older JSX typing setups often depended on editor plugins because standard TypeScript did not see enough Vue-specific intent at the JSX call site. The plugin had to create a virtual file or extra type context so that props, emits, slots, and refs could be checked like Vue.
Vue JSX 3.3 moves that intent into package declarations:
jsxImportSourceselectsvue-jsx/jsx-runtime.- The runtime exposes the
JSXnamespace. - TypeScript asks
LibraryManagedAttributes<Component, Props>for the final attribute type. - Vue JSX answers with props plus generated
onXxx, typedref, and slot children throughv-slots.
Macros and editor-only syntax helpers can still exist as optional extensions. They are not the default type story. For normal component authoring, the source of truth is the component's TypeScript type, and the inference runs in the native TypeScript checker.