Skip to content

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:

json
{
  "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:

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
ts
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.

  1. Constructor components, including components returned by Vue's defineComponent, expose an instance type. Vue JSX reads $props, $slots or slots, and the optional exposed shape from that instance.

  2. Direct function components expose their information through the function signature: first parameter for props, second parameter for slots, emit, and expose.

  3. 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:

ts
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.

tsx
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.

ts
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:

tsx
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.

ts
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:

  1. It accepts both Vue SlotsType and plain slot records.
  2. 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 the v-slots directive.
tsx
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:

  1. jsxImportSource selects vue-jsx/jsx-runtime.
  2. The runtime exposes the JSX namespace.
  3. TypeScript asks LibraryManagedAttributes<Component, Props> for the final attribute type.
  4. Vue JSX answers with props plus generated onXxx, typed ref, and slot children through v-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.