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',componentPropsis stillany, 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
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:
componentcan only be a registered component name (including two-level paths likeInput.Textarea)componentPropschanges based oncomponentdecorator/decoratorPropsare linked as a pairslotsalign 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
componentProps.auto-completion- 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):
- Keep the key only if the value is
ComponentClass - Drop overly wide keys like
string extends Kto avoid dirty keys polluting the path union
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:
- When
FormItemis chosen,decoratorPropsis FormItem's props - When another decorator is chosen, props follow accordingly
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:
- For each path
K, generate{ decorator?: K; decoratorProps?: ... } - 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:
- IDE prioritizes prompting common literals
- Yet doesn't widen the field to bare
string(which sometimes washes out union hints)
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:
Componentslocked withas constor function generics to preserve literalsPath = keyof Components | \${keyof}.sub-component``GetComponent(Path)→PropsSchema = { [K in Path]: { component?: K; componentProps?: PropsOf<K> } }[Path]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:
- Paths are currently designed for two levels; deeper
A.B.Cis 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. - Not all Vue component shapes can perfectly do
InstanceType['$props']; complex HOCs / functional components need extra adaptation. - 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.
- When too many components are registered, using the Vue template syntax causes
volarplugin'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
Summary
Reviewing the chain:
- Use a registry to lock the
Componentsliteral type - Use template literals +
ExtractChildrento supportInput.Textarea - Use Mapped Type to collect unions, linking
component/componentPropsanddecorator/decoratorProps - Use the identity function
defineSchemato attach types onto business config - 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.
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
雀食牛鼻
共勉[泣不成声]