Serialization
The serialization API converts runtime annotated types to and from a plain JSON format. This enables transferring type definitions between backend and frontend, storing them in databases, or caching compiled types.
import {
serializeAnnotatedType,
deserializeAnnotatedType,
buildJsonSchema,
} from '@atscript/typescript/utils'Purpose
Serialize type definitions on the server and send them to the client. The client deserializes them and uses them for validation, live form tools, or schema-driven UI helpers without bundling the original .as files.
Basic Usage
import { Product } from './product.as'
// Serialize to a JSON-safe object
const serialized = serializeAnnotatedType(Product)
const json = JSON.stringify(serialized)
// ... transmit, store, or cache ...
// Deserialize back to a live type
const restored = deserializeAnnotatedType(JSON.parse(json))
// The restored type is fully functional
restored.validator().validate(data)
buildJsonSchema(restored)Deserialized Types Are Live
A deserialized type is a fully functional TAtscriptAnnotatedType, built from the payload as a snapshot: a change made to the source types afterwards (a mutating annotate block loaded later, a metadata edit) does not reach it, and a change made to one of its props does not reach the deserialized types the props were inherited from (each payload builds its own nodes).
.validator()creates a workingValidatorinstance- Works with
buildJsonSchema()andforAnnotatedType() isAnnotatedType()returnstrue- Metadata is accessible via
.metadata.get() - The
idfield (type name) is preserved through serialization, sobuildJsonSchemawill still produce$defs/$reffor deserialized types
Versioning
The serialized output includes a $v field with the format version (currently 2). If the format changes in a future release, deserializeAnnotatedType() will throw when it encounters an incompatible version, so you know to re-serialize from the source types.
import { SERIALIZE_VERSION } from '@atscript/typescript/utils'
// SERIALIZE_VERSION === 2A server may add an $i18n map of translated texts to the payload for the client; deserializeAnnotatedType() ignores it. See Translating Texts.
FK References
FK references (.ref) are stripped from serialized output by default. Pass refDepth: 1 to include immediate refs when the client needs to discover the target table (e.g., for value-help dropdowns on FK fields). Integer values expand N full levels; a fractional .5 part (e.g. refDepth: 0.5 or refDepth: 1.5) emits a shallow { id, metadata } target at the tail level instead of the full body — handy for keeping payloads small when the client only needs the target's identity. Plain references (nav props such as customer: Customer) carry ref too, not only chain refs — see The Annotated Type. For the full ref-control semantics, see the @atscript/db docs.
Type references inside annotation values (since 0.1.100)
Annotation values that hold a type reference — a ref argument (target: () => Dict) or the { type, field } of a qualified query field — are serialized as shallow targets instead of disappearing from the JSON:
refDepth | Serialized reference |
|---|---|
0 (default) | { id: 'Dict' } |
> 0 (e.g. 0.5, 1) | { id: 'Dict', metadata: { … } } — the target's interface-level annotations |
References inside that metadata are always { id }, so the output is bounded and cycle-free. A chain ref such as AttributeValue.value serializes as { type: { id, metadata }, field: 'value' }, the same shape as a prop's shallow ref. Plain objects and arrays are walked; every other value is left as is. A function is treated as a type-reference getter only when it is a zero-arity, plain synchronous arrow function (the shape the generated code emits); classes, function expressions, async functions and other functions are never invoked. SERIALIZE_VERSION is unchanged: these values used to serialize to nothing, and deserializeAnnotatedType keeps them as plain records.
A named type used more than once
A named type (a node with an id) is serialized in full once; every further use is a { kind: '$ref', id } entry, which also keeps cyclic types finite. When a later use carries something of its own — prop-level annotations merged over the type's, its own optional, or its own FK ref (another target field, or another target type that shares the alias type, as customerCode: Customer.code and supplierCode: Supplier.code do) — the entry is marked own: true and carries that use's metadata, optional and, when refDepth > 0, its ref. deserializeAnnotatedType then builds a separate node over the shared type, so each prop keeps its own annotations and .ref. Annotation values are compared structurally; a type reference in them (an argument such as @ui.valueHelp Country) is compared by the type it resolves to (by id), so two uses that bind different targets are own and two that bind the same target still collapse, while any other function or class instance is compared by reference. A use that adds nothing carries no own flag, an empty metadata, and restores to the very same node as the first use. processAnnotation is only consulted for entries that carry metadata (the first use and own entries), not for collapsed ones. Cyclic plain objects inside an annotation value are cut at the repeat ('[Circular]'), and BigInt values are tolerated. Distinct types that share a name (two modules each declaring a Helper) are kept apart: the first keeps its id, each further one is serialized in full under a unique payload id (Helper@1, Helper@2, …) with origId: 'Helper', and its $ref entries use that payload id. deserializeAnnotatedType restores origId as the type's id, so every restored Helper is named Helper again. Payloads without origId (older serializers) deserialize as before. A reference without an id of its own that reaches itself — a reference to a prop of a type from inside that prop (interface T { d?: { x?: T.d } }) — is keyed the same way under a payload id (Recursive, Recursive@1, …) marked anon: true, so its repeat is a $ref too; deserializeAnnotatedType restores it without an id. The same goes for any other type without an id that the tree holds in several places (types refer to each other's nodes rather than copying them: a prop inherited through extends or an intersection, a chain reference T.a used twice): it is serialized once, and its other uses are $ref entries.
Filtering Annotations
Use TSerializeOptions to control which annotations are included in the output. This is useful for stripping sensitive or server-only metadata before sending types to the client.
Strip specific annotations:
const serialized = serializeAnnotatedType(Product, {
ignoreAnnotations: ['db.table', 'db.mongo.collection'],
})Transform annotations with a callback:
const serialized = serializeAnnotatedType(Product, {
processAnnotation({ key, value, path, kind }) {
// Only keep meta.*, expect.*, and ui.* annotations
if (key.startsWith('meta.') || key.startsWith('expect.') || key.startsWith('ui.')) {
return { key, value }
}
// Return undefined to strip
},
})The processAnnotation callback receives:
key— annotation name (e.g.'meta.label')value— annotation valuepath— property path as astring[]array (e.g.['address', 'city'])kind— type kind at this node ('','object','array', etc.)
Annotation overrides
annotationOverrides(type) lets the caller add, replace or remove annotations per node without touching the runtime metadata. It receives the annotated type node that owns each serialized metadata block (the root, props, items, union/tuple members, full ref bodies, shallow ref targets, and types referenced from annotation values) and returns a record of overrides, or undefined for none:
const serialized = serializeAnnotatedType(Product, {
annotationOverrides(type) {
if (type === Product) {
return { 'db.http.path': '/api/products', 'meta.description': undefined }
}
},
})- A key absent from the node is added, an existing key is replaced, and a key whose value is
undefinedis removed. - Overrides are applied first.
ignoreAnnotationsandprocessAnnotationthen filter the merged result, so an override can still be ignored, andprocessAnnotationsees the overridden value. - Keep the function pure in the node. A repeated named type serializes its metadata once; later uses that add nothing collapse to a
$refand restore to the first node, which carries its overrides. - Runtime metadata is never modified. Without the option, the output is unchanged.
Example: Server-Driven Field Tools
A practical use case: the server serializes a type definition and the client uses it to build a field list with labels and placeholders.
Server (Express endpoint):
import { User } from './user.as'
import { serializeAnnotatedType } from '@atscript/typescript/utils'
app.get('/api/form/user', (req, res) => {
const schema = serializeAnnotatedType(User, {
ignoreAnnotations: ['db.table', 'db.mongo.collection'], // strip server-only metadata
})
res.json(schema)
})Client (Vue component):
<script setup>
import { ref, onMounted } from 'vue'
import { deserializeAnnotatedType } from '@atscript/typescript/utils'
const fields = ref([])
const formData = ref({})
onMounted(async () => {
const res = await fetch('/api/form/user')
const type = deserializeAnnotatedType(await res.json())
// Build UI field data from type metadata
for (const [name, prop] of type.type.props.entries()) {
fields.value.push({
name,
label: prop.metadata.get('meta.label') || name,
placeholder: prop.metadata.get('ui.placeholder') || '',
})
formData.value[name] = ''
}
})
</script>
<template>
<form>
<div v-for="field in fields" :key="field.name">
<label>{{ field.label }}</label>
<input v-model="formData[field.name]" :placeholder="field.placeholder" />
</div>
</form>
</template>The field list, labels, and placeholders are all driven by annotations defined in the .as file, so the client does not need to duplicate that configuration.
Next Steps
- Type Definitions — the annotated type system
- Validation — validate data against types
- Metadata — access annotations at runtime
- Translating Texts — translate a serialized type per locale with
translateServed()