blocks-ui has around fifty web components, and until today none of them had a formal declaration of what properties they accept. The @property() decorators on each class were typed, but nothing enforced that contract from the outside — a YAML author could pass arbitrary keys and they’d silently pass through. Sixteen components had configure(props: Record<string, unknown>) methods that made this explicit: anything goes.

The fix is a ts-morph generator that reads a BlocksComponentRegistry interface and walks each component’s property types structurally, producing Zod schemas. The approach mirrors what casehub-pages landed with its own ComponentTypeRegistry and component-schemas.generated.ts, so the two systems are consistent — same generator shape, same schema map, same staleness test.

The interesting design problem was where to put the registry. The natural home would be blocks-ui-core, but that creates a circular dependency: components depend on core for domain types, core would need to import Props interfaces from components. The solution was to put the registry in the new blocks-ui-schema package alongside the generator, using import type from each component package as dev dependencies. The generator resolves types at generation time via tsconfig paths that point directly at source files — no build-order dependency, no stale dist/ problem.

That dist/ issue cost us time. ts-morph resolves @scope/package through package.json’s types field, which points at dist/index.d.ts. When the dist is stale (built before the Props interface was added), the type name resolves correctly — getSymbol() returns the right name — but getProperties() returns an empty array. The misdirection is convincing: the obvious diagnostic says the type was found. The fix was a separate tsconfig.generator.json with paths mapping packages directly to their source directories, while the build tsconfig stays clean. That split — one tsconfig for generation, one for compilation — is reusable anywhere a monorepo runs ts-morph against sibling packages.

The generator handles Lit’s render callbacks (functions passed as properties for customisation) by filtering them out — isFunction() on the type plus an explicit FUNCTION_PROPS set. Domain types like ExecutionSnapshot and QuorumConfig are walked structurally to depth six, producing nested z.object() schemas that capture the full shape. The depth guard prevents runaway recursion; anything past six levels falls back to z.unknown().

We also typed eight configure() methods from Record<string, unknown> to Partial<FooProps>, removing the manual as casts. The compiler now enforces that configure() only accesses declared properties. Six internal document-workbench sub-components still have the untyped pattern — they’re non-blocks- prefixed internals that don’t appear in YAML, so the schema registry doesn’t cover them. Deferred, not forgotten.

The schemas are published as a Maven SNAPSHOT via the existing WebJar pattern. Downstream, pages-code-editor can import blocksComponentSchemaMap and merge it with the pages schema map — that wiring is separate work, but the contract is ready. A registry completeness test scans for every @customElement('blocks-*') in the repo and verifies it has a schema entry, so new components can’t slip through without being registered.

What I find satisfying about this is the layering. The Props interfaces are the contract. The registry aggregates them. The generator produces schemas mechanically. The staleness test keeps them honest. The completeness test prevents drift. Each layer is independently testable and none of them require the full workspace to be built — the generator reads source directly. That independence matters in a monorepo with fifty packages.


<
Previous Post
When the Safety Review Doesn't Know the Grade Changed
>
Next Post
Two thousand ghosts in the database