跪拜 Guibai
← Back to the summary

Deriving Full TypeScript IntelliSense for JSON Schema Forms from a Vue3 Component Registry

Vue3 Low-Code: How I Made Schema componentProps Auto-Suggest

When writing schema-driven forms, the most annoying part isn't rendering — it's that after writing component: 'Radio', componentProps is still any, and wrong field names only blow up at runtime.
This article covers: in Vue3, how to derive Schema types all the way from a "component registry" so the IDE can prompt props / slots / decorator while you write the config.

The article is hands-on, weaving in conditional types, template literal types, mapped types, and a small trick for when Vue3's "generic component support is weak." The thinking references Formily-style schema form solutions; the implementation is a simplified version from our own project.


First, the DX we want

image.png

The ideal usage looks roughly like this:

const { SchemaField, defineSchema } = createSchemaField({
  components: {
    Input,
    Select,
    Radio,
    FormItem,
    ATestComp
  }
})

const schemaJson = defineSchema([
  {
    name: 'doctorServiceAttitude',
    type: 'string',
    title: 'Doctor Service Attitude',
    decorator: 'FormItem',
    decoratorProps: {
      required: true
      // ↑ should prompt FormItem's props here
    },
    component: 'Radio',
    componentProps: {
      options: [
        { label: 'Satisfied', value: 'satisfied' }
        // ↑ should prompt Radio's props here
      ]
    }
  }
])

The goal can be broken into four points:

  1. component can only be a registered component name (including two-level paths like Input.Textarea)
  2. componentProps changes based on component
  3. decorator / decoratorProps are linked as a pair
  4. slots align as closely as possible with the component's real slots

At runtime defineSchema does almost nothing; its main purpose is to anchor the types onto the parameter:

function defineSchema(schemas: SomeTypedSchema[]) {
  return schemas // identity function, types take effect at compile time
}

DX effect shown below

image.png image.png

  1. componentProps. auto-completion
  2. Error when writing a wrong prop name

Overview: How the types are derived step by step

The whole chain can be drawn as:

components registry
    ↓ VueComponentPath        // 'Input' | 'Input.Textarea' | 'Radio' ...
    ↓ GetComponentByPath      // path → component constructor type
    ↓ ComponentProps / Slots  // InstanceType gets $props / $slots
    ↓ VueComponentTypeData    // { componentProps, decorator, slots }
    ↓ SchemaType / defineSchema
    ↓ (optional) MarkupField's as new <T>() generic disguise

The factory function entry roughly looks like:

export function createSchemaField<Components extends SchemaVueComponents>(
  options: SchemaFieldOptions<Components>
) {
  type ComponentPropsMap = {
    [P in VueComponentPath<Components>]: ComponentPropsMapValue<Components, P>
  }

  type ComponentSlotsMap = {
    [P in VueComponentPath<Components>]: ComponentSlotsMapValue<Components, P>
  }

  return _createSchemaField<ComponentPropsMap, ComponentSlotsMap, Components>(options)
}

Key point: Components is the literal object type passed in by the caller. As long as the registry is written correctly, the subsequent Path / PropsMap can all be derived.


1. First, a convention: How to get Props / Slots from a component

Vue components are not unified in the type world. We commonly use a "constructor" perspective:

export type ComponentClass = abstract new (...args: unknown[]) => any

export type ComponentProps<T extends ComponentClass> = InstanceType<T>['$props']
export type ComponentSlots<T extends ComponentClass> = InstanceType<T>['$slots']

Meaning: treat the component as a class that can be new-ed, then take $props / $slots from the instance.
This works well enough with defineComponent / some SFC types; for functional components or strange wrapper layers, an extra adaptation layer may be needed (we're still refining this in our project).

There's also a small Helper used to reverse-derive a component type from a props shape (a common pattern among library authors):

class Helper<Props> {
  Return = defineComponent({} as Record<keyof Props, any>)
}

export type DefineComponent<Props> = Helper<Props>['Return']

2. Component paths: supporting Input and Input.Textarea

Material libraries often have "main component + sub-component":

export const Input = composeExport(InnerInput, { Textarea })
// runtime: Input.Textarea
// type-wise: want component to be able to write 'Input' | 'Input.Textarea'

1) Extract "sub-components attached to a component"

export type ExtractChildren<T> = T extends object
  ? {
      [K in keyof T as T[K] extends ComponentClass
        ? string extends K
          ? never
          : number extends K
            ? never
            : symbol extends K
              ? never
              : K
        : never]: T[K] extends ComponentClass ? T[K] : never
    }
  : Record<string, never>

This uses key remapping (as):

2) Generate the path union

export type VueComponentPath<
  T extends SchemaVueComponents,
  Key extends keyof T = keyof T
> = Key extends string
  ? T[Key] extends VueComponent
    ? `${Key}.${Extract<keyof ExtractChildren<T[Key]>, string>}` | Key
    : Key
  : never

So after registering Input (with Textarea) and Radio, the paths are roughly:

'Input' | 'Input.Textarea' | 'Radio' | ...

3) Resolve path to component type

Currently we only seriously support two-level paths (enough to cover Input.Textarea / Input.Search and the like):

export type GetComponentByPath<
  T extends ComponentMap,
  Path extends string
> = Path extends `${infer First}.${infer Second}`
  ? First extends keyof T
    ? T[First] extends ComponentMap
      ? Second extends keyof T[First]
        ? T[First][Second] extends ComponentClass
          ? T[First][Second]
          : never
        : never
      : never
    : never
  : Path extends keyof T
    ? T[Path] extends ComponentClass
      ? T[Path]
      : never
    : never

Template literal types + infer essentially do a "split by ." inside the type system.

Once we have the component, Props / Slots can be wrapped with an infer to avoid repeatedly expanding the same key:

export type ComponentPropsMapValue<
  Components extends SchemaVueComponents,
  P extends string
> = GetComponentByPath<Components, P> extends infer C
  ? C extends ComponentClass
    ? ComponentProps<C>
    : never
  : never

3. How Decorator and DecoratorProps "pair up"

Just prompting decorator: 'FormItem' isn't enough; we also want:

The approach: first build an "object type indexed by path", then take the union index to collect into a discriminated union:

export type DecoratorType<T extends SchemaVueComponents> = {
  [K in VueComponentPath<T>]?: {
    decorator?: K
    decoratorProps?: T[K] extends ComponentClass
      ? Partial<ComponentProps<T[K]>>
      : Record<string, never>
  }
}[VueComponentPath<T>]

Reading it:

  1. For each path K, generate { decorator?: K; decoratorProps?: ... }
  2. Finally use [VueComponentPath<T>] to "flatten" the whole object type into a union

This is a very practical pattern in TS: Mapped Type builds a table, indexed access collects a union.
It's not only useful for low-code; any config object where "type determines payload" can apply it.

A complete component-side data type block can look like:

export type VueComponentTypeData<
  Component extends ComponentClass,
  Components extends SchemaVueComponents
> = {
  componentProps?: Partial<ComponentProps<Component>>
  decorator?:
    | DecoratorType<Components>
    | {
        decorator?: null | undefined
        decoratorProps?: { [K: string]: never }
      }
  slots?: {
    [key in keyof ComponentSlots<Component>]?:
      | ((
          ...args: Parameters<ComponentSlots<Component>[key]>
        ) => ReturnType<ComponentSlots<Component>[key]>)
      | string
      | number
      | VNode
      | VNode[]
  }
}

decorator: null gets its own branch, convenient for fields that "don't want a decorator."


4. Collecting "component name → type data packet" into a Schema union

With each component's corresponding componentProps / decorator / slots, the next step is to generate the Schema array element type:

export type SchemaType<
  T extends Record<string, unknown>,
  Props extends Record<string, unknown> = Record<string, unknown>
> = {
  [K in keyof T]: T[K] extends Record<string, unknown>
    ? Omit<JsonSchema<...>, 'children'> & {
        component?: K
        componentProps?: T[K]['componentProps']
        slots?: T[K]['slots']
        children?: SchemaType<T>[]
      } & T[K]['decorator'] &
        Props
    : never
}[keyof T]

Again, "build a table then [keyof T] to flatten."
So when component: 'Radio', the componentProps on the same object gets narrowed to the Radio branch.

defineSchema attaches this layer of types onto the parameter:

function defineSchema<Component extends keyof ComponentPropsMap>(
  schemas: SchemaType<
    {
      [P in Component]: ComponentPathToVueComponentPath<Components, P & string>
    }
  >[]
) {
  return schemas
}

Where:

export type ComponentPathToVueComponentPath<
  Components extends SchemaVueComponents,
  P extends string
> = GetComponentByPath<Components, P> extends infer C
  ? C extends ComponentClass
    ? VueComponentTypeData<C, Components>
    : never
  : never

At this point, the type DX main chain for JSON Schema config is connected.


5. When Vue3 generic components are weak: using as new <T>() as a workaround

Besides defineSchema, we also have a Markup syntax (<SchemaField.String component="Input" /> and the like).
Vue3's support for "components that themselves carry generics" is mediocre; a common library pattern is:

At runtime it's still a plain defineComponent, but type-wise it's asserted as a generic constructor.

import type { CreateComponentPublicInstanceWithMixins } from 'vue'

const MarkupField = defineComponent({
  name: 'MarkupField',
  props: { /* ... */ },
  setup(props, { slots }) {
    // ...
  }
}) as new <
  Decorator extends keyof ComponentPropsMap | ComponentClass,
  Component extends keyof ComponentPropsMap | ComponentClass
>(
  props: ISchemaMarkupFieldProps<Decorator, Component, ComponentPropsMap, ComponentSlotsMap>
) => CreateComponentPublicInstanceWithMixins<
  ISchemaMarkupFieldProps<Decorator, Component, ComponentPropsMap, ComponentSlotsMap>
>

Inside ISchemaMarkupFieldProps, depending on whether Decorator / Component is a "string key" or a "component class," values are taken from PropsMap or ComponentProps respectively.

Benefit Cost
TSX can get hints close to a generic component The assertion is rather "hard" and must align with runtime props definition
Can reuse the existing PropsMap Hinting ability in .vue templates is still limited by Vue tooling
Controllable for library authors Maintenance cost is high for newcomers; changing types requires care

If your business only uses defineSchema, you can even skip the Markup generics for now, which lowers complexity significantly.

DX effect shown below


6. A small trick: string & Record<string, unknown>

Schema protocols often have "recommend enum, but allow extension":

export type SchemaTypes =
  | 'string'
  | 'object'
  | 'array'
  | 'number'
  | 'boolean'
  | 'void'
  | 'date'
  | 'datetime'
  | (string & Record<string, unknown>)

And similarly for display / pattern etc. The purpose is:

The community often calls it a LiteralUnion-style trick. Similar patterns can be seen in Formily-related types.
Note: different TS versions and different tools have slightly different hinting effects; don't mythologize it.


7. A reusable "minimum recipe" (for readers who want to copy)

If you're not building a full form engine and just want "registry → config type," the minimum closed loop is:

  1. Components locked with as const or function generics to preserve literals
  2. Path = keyof Components | \${keyof}.sub-component``
  3. GetComponent(Path)Props
  4. Schema = { [K in Path]: { component?: K; componentProps?: PropsOf<K> } }[Path]
  5. defineConfig(config: Schema[]) { return config }

Get single-level paths working first, then add Input.Textarea, and finally consider decorator linkage and Vue generic disguise.
Step by step is far less discouraging than copying a complete type file all at once.


8. Pitfalls we stepped on

A type article that only flexes muscle is hard to trust. Let me sync a few current realities:

  1. Paths are currently designed for two levels; deeper A.B.C is not treated as a first-class citizen, because extracting with recursive types is too performance-heavy, and two levels are enough for the vast majority of component libraries or components.
  2. Not all Vue component shapes can perfectly do InstanceType['$props']; complex HOCs / functional components need extra adaptation.
  3. The design inspiration comes from mature solutions like Formily; the value lies in landing "registry-driven Schema types" in Vue3 engineering, because Formily's Json Schema writing doesn't have fairly comprehensive type hints.
  4. When too many components are registered, using the Vue template syntax causes volar plugin's attribute suggestions to fail (the attribute suggestions of the volar plugin in Vue templates are not lazy; it extracts all generic cases into one large union type, and when types are too many it causes type explosion). Later, through optimization, registering 50 components works fine.

Attribute suggestion effect for Vue template syntax

image.png


Summary

Reviewing the chain:

  1. Use a registry to lock the Components literal type
  2. Use template literals + ExtractChildren to support Input.Textarea
  3. Use Mapped Type to collect unions, linking component / componentProps and decorator / decoratorProps
  4. Use the identity function defineSchema to attach types onto business config
  5. When Markup is needed, use as new <T>() to compensate for Vue's weak generic component support

If you're also building low-code, form engines, or "JSON config-driven UI," I hope this breakdown saves you some detours.

Questions welcome in the comments; if you'd like me to break a section into "a 30-line runnable minimal version written from scratch," just say so, and I can write a follow-up.

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

请叫我彭彭

雀食牛鼻

水寒259

共勉[泣不成声]