跪拜 Guibai
← All articles
Architecture

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

By 水寒259 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Schema-driven form engines are common in low-code platforms, but writing config objects where `componentProps` is `any` pushes prop errors to runtime. This technique catches them in the IDE instead, and the mapped-type-to-union pattern generalizes to any TypeScript config where a discriminant key should narrow a sibling's type.

Summary

A type-level pipeline turns a Vue3 component registry into a fully typed JSON Schema config. When a developer writes `component: 'Radio'`, the `componentProps` field narrows to Radio's actual props — and `decorator: 'FormItem'` does the same for `decoratorProps`. The chain runs from a literal `Components` object through template-literal path types (`'Input' | 'Input.Textarea'`), mapped types that build per-path data packets, and an identity `defineSchema` function that anchors the types at compile time with zero runtime cost.

Two-level component paths like `Input.Textarea` are supported via `ExtractChildren` and `infer`-based string splitting. Decorator linkage uses the "mapped type builds a table, indexed access collects a union" pattern — a reusable technique for any config where a discriminant field determines the shape of a sibling field. For Markup/TSX usage, a `defineComponent` is type-asserted as a generic constructor (`as new <T>()`) to work around Vue3's weak native generic component support.

The implementation draws inspiration from Formily but targets a gap: Formily's JSON Schema authoring lacks comprehensive type hints. Known limits include two-level path depth (deeper recursion is too expensive for the type checker), incomplete `InstanceType['$props']` extraction for some HOC/functional components, and Volar type-explosion when too many components are registered — mitigated to handle 50 components.

Takeaways
A literal `Components` object passed to `createSchemaField` drives the entire type derivation; no extra type annotations are needed from the caller.
`VueComponentPath` generates a union like `'Input' | 'Input.Textarea' | 'Radio'` using template literal types and an `ExtractChildren` helper that filters sub-components by `ComponentClass`.
`GetComponentByPath` resolves a path string to a component constructor via `infer First.Second` pattern matching, currently supporting exactly two levels.
`ComponentProps<T>` and `ComponentSlots<T>` are defined as `InstanceType<T>['$props']` and `InstanceType<T>['$slots']`, treating the component as an abstract `new`-able class.
Decorator linkage uses a mapped type over all paths, then `[VueComponentPath<T>]` to flatten into a discriminated union — selecting `decorator: 'FormItem'` narrows `decoratorProps` to FormItem's props.
`defineSchema` is an identity function that exists only to attach the derived `SchemaType` to its parameter; it returns the input unchanged at runtime.
For Markup/TSX usage, a `defineComponent` is asserted `as new <T>()` to simulate a generic component constructor, since Vue3's native generic component inference is weak.
The `string & Record<string, unknown>` trick preserves literal union suggestions in the IDE while still allowing arbitrary string extensions.
A minimum reproducible recipe needs only five steps: lock `Components` literals, build path unions, resolve path to props, map into a schema union, and wrap with an identity config function.
Volar's template attribute suggestions are not lazy and can explode into a giant union when many components are registered; the implementation was optimized to handle 50 components.
Conclusions

The mapped-type-then-indexed-access pattern (`{ [K in Paths]: {...} }[Paths]`) is underused in application code but solves a recurring problem: making two sibling fields in a config object discriminate on each other without writing explicit discriminated unions by hand.

Vue3's type system treats components inconsistently — `InstanceType<T>['$props']` works for `defineComponent` and many SFCs but breaks on functional components and complex HOCs, which means a production registry needs an adapter layer the article only gestures at.

The two-level path limit is a deliberate engineering tradeoff, not a TypeScript limitation: recursive template literal types can go deeper, but the compile-time cost becomes prohibitive for a dev-experience feature.

The `as new <T>()` assertion on a runtime `defineComponent` is a pragmatic hack that decouples IDE experience from Vue's actual generic inference, but it creates a maintenance contract — the asserted type must stay manually aligned with the runtime props definition.

Formily's JSON Schema authoring lacking comprehensive type hints is a measurable gap in an otherwise mature ecosystem; this implementation fills it specifically for Vue3, where the generic-component weakness makes the problem harder than in React.

Concepts & terms
Mapped Type → Indexed Access union pattern
A TypeScript pattern where you first build an object type with a mapped type over a union of keys, then immediately index it with the same union (`T[K]`) to collapse it into a discriminated union. Used here to make `decorator` and `decoratorProps` discriminate on each other.
Template literal path types
Using `${infer First}.${infer Second}` in a conditional type to split a string literal like `'Input.Textarea'` into two parts at compile time, then resolve each part against a component registry.
Key remapping with `as`
A TypeScript 4.1+ feature inside mapped types that lets you filter or transform keys. Used in `ExtractChildren` to keep only keys whose values extend `ComponentClass` and drop overly wide keys like `string`.
`string & Record<string, unknown>` (LiteralUnion trick)
Intersecting `string` with a non-empty object type prevents TypeScript from widening a literal union to plain `string`, preserving IDE autocomplete for the literals while still accepting arbitrary strings.
`as new <T>()` generic constructor assertion
A type-level workaround for Vue3's weak native generic component support: a runtime `defineComponent` is type-asserted as a generic class constructor so that TSX call sites receive proper generic inference, at the cost of manually keeping the assertion aligned with runtime props.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗