Type Inference: 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 — including the new TypeScript 7 (the native tsgo port), where it works out of the box.
The key is JSX.LibraryManagedAttributes. TypeScript calls this type whenever it checks <Comp ... />. Vue JSX uses that hook to extend the component's props: normal props stay normal, emitted events become onXxx, ref points at the exposed type, and JSX children are checked as Vue slots. Vapor components take the same path: components wrapped in defineVaporComponent and plain vapor function components get the same props, emits, slots, and exposed-ref inference as Virtual DOM components.
The Entry Point
The only default TypeScript setup is the JSX runtime:
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "vue-jsx"
}
}vue-jsx/jsx-runtime exports the runtime JSX namespace that TypeScript resolves through jsxImportSource.
Every rule TypeScript applies when checking JSX comes from this namespace: what counts as an element, and where props and children are read from and how they are extended. It is defined 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 points the children check at the v-slots prop type. The real type-level adaptation is left to LibraryManagedAttributes.
What LibraryManagedAttributes Does
Here is the important part of the type, from the runtime declarations:
Expand the full LibraryManagedAttributes type
export type LibraryManagedAttributes<Component, Props> = Omit<Props, 'ref'> &
(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: any,
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)} />
export default () => (
<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
In Vue JSX 3.3, ref no longer just passes VNodeRef through: it derives a more precise type from what the component exposes. 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>
}
export default () => (
<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.
export type SlotsToProps<
RawSlots extends SlotsType | Record<string, any> = Record<string, any>,
Slots = 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>
}The trick is how the two types pair up. LibraryManagedAttributes first generates a typed v-slots prop carrying the component's slot signatures; ElementChildrenAttribute then tells TypeScript to check JSX children against that v-slots prop instead of a children prop — it takes over the position children occupies in React. Vue JSX never had a children prop concept, but v-slots is already a directive the compiler understands, so the type layer and the compile layer meet at the same attribute. When you write <Panel>{({ active }) => ...}</Panel>, TypeScript is actually matching your children function against Slots['default'] | Slots on v-slots, and active is inferred from the default slot signature.
This helper does several small but important things:
- It accepts both Vue
SlotsTypeand plain slot records. - It models the children forms: when the component has
default, the slot function can be passed directly as children; a full slots object works too — either as children directly, or via thev-slotsdirective.
import { defineComponent } from 'vue'
const Panel = defineComponent(
(_, { slots }: { slots: { default?: () => []; footer?: () => [] } }) => {
return () => <section>{slots.default?.()}</section>
},
)
export default () => (
<>
<Panel>{() => <p />}</Panel>
<Panel>{{ default: () => <p />, footer: () => <p /> }}</Panel>
<Panel v-slots={{ default: () => <p />, footer: () => <p /> }} />
</>
)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.
Try It
Stepper is a plain vapor function component exercising all four attributes at once: typed props (init), emit surfaced as onChange (with value: number inferred in the handler), the default slot written as children (with the value ref typed from the slot signature), and expose flowing into the type of the ref callback — that is where reset comes from.