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 setserializeZod 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.

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.