Schema serialization: runtime ↔ serialized round-trip
At a glance
- Converts a schema (live Zod validators + live functions) to a JSON-safe form and back —
serializeSchema/deserializeSchema. - Only 7 Zod shapes survive the round-trip (
string,number,boolean,any,enum,literal,array) — anything else errors, it doesn't silently drop detail. - Functions serialize as their own source text, and
toString()is patched to keep returning that same text after deserializing. - All conversions return a
neverthrowResult, never throw.
A schema here is not the tree/entry model from Tree nodes and schemas — it's one level up: the definition of what's editable for a given file type, including live Zod validators and live JavaScript functions (prefill, mutate). Neither Zod schemas nor functions survive JSON.stringify, so anything that needs to store or transmit a schema (the database, an API response, a template's saved config) needs a JSON-safe stand-in it can convert back later. That's what this package does: convert a RuntimeSchema to a SerializedSchema and back.
Runtime vs. Serialized
Every shape in this package comes in two parallel forms:
Runtime (types/runtime.ts) | Serialized (types/definition.ts, types/config.ts) |
|---|---|
RuntimeSchema | SerializedSchema |
RuntimeSchemaMap / RuntimeSchemaEntry | SerializedSchemaMap / SerializedSchemaEntry |
RuntimeSchemaConfig (sections of admin-configurable options, each with a live mutate function) | SerializedSchemaConfig |
The two are structurally identical except wherever the runtime side holds a live Zod type or a live function, the serialized side holds a descriptor instead:
- Zod validators become a
ZodDescriptor— a small tagged union ({ zod: 'string' },{ zod: 'number' },{ zod: 'boolean' },{ zod: 'any' },{ zod: 'enum', values },{ zod: 'literal', value }, or{ zod: 'array', items }recursively). This is a closed set —serializeZodonly recognizes those seven shapes (zod-guards.tschecks each one byinstanceofor by reading_def.typeName, so it also works across separately-loaded Zod instances). A validator built from anything else (z.object,z.union,z.record, ...) makesserializeZodreturn aZodSerializationErrorrather than silently dropping detail. - Functions (
prefillon an entry,mutateon a config section) become anFnDescriptor— literally{ body: string }, the function's own.toString()output. (FnDescriptoritself isn't re-exported from the package entry point, so it has no Reference page — it only shows up as the type ofprefill/mutateon the entry/section types above.) Deserializing runs that string back through theFunction(...)constructor.deserializeFnthen patches the rebuilt function'stoString(viawithSource) to return the original body text — so if you serialize a function, deserialize it, and serialize it again, you get the same source string back rather than V8's genericfunction anonymous() { [native code] }wrapper.
The conversion functions
serializeSchema and deserializeSchema are the entry points; both return a neverthrow Result<T, ParserError> rather than throwing, so callers handle failure explicitly instead of wrapping every call in try/catch. Internally each walks the schema recursively — map → its values (entries or nested maps) → each entry's validation/prefill — mirroring the recursive shape of RuntimeSchemaMap/SerializedSchemaMap itself. deserializeSchema additionally runs the input through serializedSchemaSchema.safeParse first (a real Zod schema describing the serialized shape) — so malformed input coming from outside the app (a hand-edited database row, a bad API payload) is rejected before any recursive walking starts, rather than throwing partway through.
ParserError has three cases: ZodDeserializationError, ZodSerializationError, FunctionDeserializationError — errors are categorized by which side of the conversion failed, not by which field.
Changing these shapes breaks existing saved data
There is no version field and no migration step. deserializeSchema either parses what it's given against the current shape or fails — nothing translates an older payload into a newer one.
So a change to RuntimeSchemaMap, SerializedSchemaMap or the entry shapes they contain can leave previously saved schemas unreadable. If you make one, say so explicitly in the pull request.
Where this fits
A RuntimeSchema's config section is what TemplateSave.config snapshots (configSnapshot) come from, and a schema's definition is the template form of the tree model described elsewhere.
Extending the Zod descriptor set
If you're adding a new kind of value a schema's Zod validators need to express, it has to be added to the closed set in zod-serialize.ts / zod-deserialize.ts / zod-guards.ts together — all three, or serialization will start failing for that field with a ZodSerializationError rather than a helpful compile error.