Skip to content

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 neverthrow Result, 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)
RuntimeSchemaSerializedSchema
RuntimeSchemaMap / RuntimeSchemaEntrySerializedSchemaMap / 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 — serializeZod only recognizes those seven shapes (zod-guards.ts checks each one by instanceof or 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, ...) makes serializeZod return a ZodSerializationError rather than silently dropping detail.
  • Functions (prefill on an entry, mutate on a config section) become an FnDescriptor — literally { body: string }, the function's own .toString() output. (FnDescriptor itself isn't re-exported from the package entry point, so it has no Reference page — it only shows up as the type of prefill/mutate on the entry/section types above.) Deserializing runs that string back through the Function(...) constructor. deserializeFn then patches the rebuilt function's toString (via withSource) 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 generic function 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.