What makes a form portable? Not JSON alone. Its validation, conditions, collections and submission semantics must survive the trip too. I wrote about the architecture behind Modyra and the trade-offs involved.
Your Form Is Not Portable If It Contains Callbacks
Most form libraries help us manage forms inside an application.
They track values, execute validators, expose errors and eventually produce a submission payload.
That works well until the form needs to exist somewhere else.
Perhaps its structure comes from a backend. Perhaps a visual builder generates it. Perhaps multiple applications must render it. Perhaps the server must independently validate the same conditional rules used by the browser.
At that point, the form is no longer just component state.
It is a contract.
And most form abstractions cannot cross that boundary.
The portability illusion
Consider a typical conditional validator:
const form = createForm({
defaultValues: {
country: 'IT',
vatId: '',
},
validators: {
onChange: ({ value }) => {
if (value.country === 'IT' && !value.vatId) {
return {
fields: {
vatId: 'VAT ID is required in Italy',
},
};
}
},
},
});
This is perfectly reasonable application code.
It is also not portable.
The callback cannot travel through an API as JSON. A Java service cannot execute it. A visual editor cannot reliably inspect it. Another runtime cannot reproduce its meaning without receiving executable source code.
We can serialize the values around the callback, but not the behavior itself.
This leads to an important distinction:
A form configuration is not a portable form contract if part of its meaning still lives inside executable callbacks.
The obvious shortcuts are dangerous
There are several tempting ways to work around this limitation.
Serialize the callback as source code
{
"condition": "value.country === 'IT'"
}
The receiving application must now parse or execute an expression encoded as text.
That creates immediate problems:
- the expression is not statically connected to the form model;
- renaming a field may not update the expression;
- invalid paths are discovered at runtime;
- every runtime needs an equivalent interpreter;
- executing arbitrary source introduces a dangerous trust boundary.
Invent a compact string DSL
{
"requiredWhen": "country=IT"
}
This looks concise until the language grows.
Soon it needs nested fields, arrays, grouping, precedence, comparisons, arithmetic, null handling and useful diagnostics.
The compact syntax gradually becomes a programming language hidden inside strings.
TypeScript cannot help much because TypeScript only sees a string.
Keep the behavior inside every client
Another option is to let the backend send the field structure while every application implements the business rules independently.
This avoids remote code execution, but destroys the main benefit of a shared contract.
Different clients can interpret the same form differently.
The contract becomes a suggestion.
Behavior can be data
The alternative is to represent behavior through a closed, declarative expression tree.
{
"op": "eq",
"left": {
"path": ["country"]
},
"right": {
"literal": "IT"
}
}
A conditional effect can also remain ordinary data:
{
"when": {
"op": "eq",
"left": {
"path": ["country"]
},
"right": {
"literal": "IT"
}
},
"then": {
"effect": "required",
"field": ["vatId"]
}
}
This representation is more verbose than a callback.
It is also:
- serializable;
- inspectable;
- versionable;
- independently validatable;
- executable by different runtimes;
- safe to reject when unsupported.
The remote document never contains JavaScript, serialized callbacks or instructions to evaluate arbitrary source code.
It only contains data from a closed vocabulary.
TypeScript for authoring, JSON for transport
Writing raw expression trees directly is not a pleasant developer experience.
The authoring format should therefore be different from the transport format.
A typed builder can expose references checked by TypeScript:
const model = fields({
supplier: group({
legalName: text()
.label('Legal name')
.required(),
country: select(countries)
.default('IT'),
riskLevel: select(
['low', 'medium', 'high'] as const
).default('low'),
}),
dueDiligence: upload()
.label('Due diligence document'),
});
const procurementForm = form({
id: 'procurement-request',
fields: model,
rules: [
when(
eq(model.supplier.riskLevel, 'high'),
require(model.dueDiligence),
),
],
});
The author writes TypeScript, receives autocomplete and cannot casually reference a field that does not exist.
The builder produces the canonical JSON representation:
const contract = procurementForm.toJSON();
The crucial requirement is that builders must produce data immediately.
They must not inspect JavaScript source, serialize callbacks or defer arbitrary functions into the resulting contract.
The pipeline should look like this:
Typed authoring
↓
Canonical contract
↓
JSON transport
↓
Runtime validation
↓
Form engine
↓
Renderer
This separation became one of the central ideas behind Modyra, the open-source form engine I have been building for TypeScript applications.
Rendering is only one consumer
Once the form becomes a contract, a frontend renderer is no longer the owner of the form.
It is one possible consumer.
An Angular application can receive a contract and render it without knowing its fields in advance:
<mdy-dynamic-form
[contract]="contract()"
/>
A framework-free application can interpret the same contract using real DOM elements.
A React application can consume the same form model through a headless adapter.
A backend can validate submitted values against the same structural expectations.
A visual editor can modify the document without generating callback source.
The objective is not identical DOM across every framework.
The objective is identical meaning.
Identical meaning is the difficult part
Rendering a text input is easy.
Preserving its semantics across boundaries is not.
Consider a positional collection:
[
{ "sku": "A", "quantity": 1 },
{ "sku": "B", "quantity": 2 },
{ "sku": "C", "quantity": 3 }
]
If the second row becomes disabled and disabled values must be excluded from submission, should the payload become this?
[
{ "sku": "A", "quantity": 1 },
{ "sku": "C", "quantity": 3 }
]
That silently changes the identity of the third row.
The item at index 2 has moved to index 1.
A safer representation may need to preserve the original position:
[
{ "sku": "A", "quantity": 1 },
null,
{ "sku": "C", "quantity": 3 }
]
That decision influences:
- submission;
- change sets;
- patches;
- undo and redo;
- draft persistence;
- server validation;
- every renderer.
This is why portability is not achieved merely by producing JSON.
The JSON needs a precise semantic contract.
The same document must receive the same verdict
A portable system can fail in subtle ways:
- the JSON Schema accepts a document that the parser rejects;
- the parser accepts an operator that the evaluator does not implement;
- the runtime validates a value differently from a backend SDK;
- one renderer treats a field as disabled while another still submits it;
- a visual editor generates an option that one client silently discards.
Every component can look correct in isolation while the whole system remains wrong.
While building Modyra, I started treating these relationships as executable claims:
A version accepted by one runtime has a defined
position in every other runtime.
A disabled positional row does not move the rows
that follow it.
A value that survives a JSON round trip preserves
the same meaning.
A renderer does not invent behavior that the
contract did not declare.
The project uses differential, conformance and adversarial tests to try to falsify these claims.
The purpose is not merely to make a test suite green.
The purpose is to discover when two entrances into the same system tell different stories.
Remote form documents are untrusted input
A server-supplied form is still external input.
Even if the server belongs to the same organization, the document might have been produced by a CMS, visual editor, migration, third-party service or generative model.
A form document can be harmful without containing executable code.
For example, this small regular expression can freeze a JavaScript thread:
(a+)+$
A near-matching input can trigger catastrophic backtracking.
Because JavaScript regular-expression matching is synchronous, the entire interface can stop processing keystrokes and repainting.
Other hostile or malformed documents can contain:
- excessive nesting;
- unsafe property paths;
- unsupported operators;
- recursive dependencies;
- invalid widget kinds;
- oversized collections;
- ambiguous contract versions.
A portable contract therefore needs more than deserialization.
It needs:
- structural validation;
- explicit limits;
- closed vocabularies;
- safe path handling;
- deterministic diagnostics;
- version negotiation;
- no dynamic code execution.
If one validator is unsafe to execute, the field itself should not necessarily disappear.
The engine can preserve the field, refuse the unsafe rule and report the degradation.
That is a better failure mode than making the form unusable.
Portability has a real cost
This architecture is not appropriate for every form.
A local login form does not need a versioned remote contract, multiple renderers, backend parsers and a conformance suite.
A focused framework-native library will usually be simpler and more appropriate.
A contract-driven approach becomes valuable when forms are:
- supplied through APIs;
- generated by tooling;
- shared across applications;
- frequently changed;
- deeply nested;
- required to behave consistently across runtimes;
- validated independently by a backend;
- governed as long-lived business artifacts.
Portability does not eliminate complexity.
It relocates complexity away from every consuming application and into shared infrastructure.
That trade only makes sense when several consumers would otherwise rebuild the same machinery independently.
What I am building
Modyra currently explores this model through:
- a typed, framework-independent form engine;
- nested positional and keyed collections;
- synchronous, asynchronous and cross-field validation;
- cancellable server validation;
- draft persistence and undo/redo;
- a validated Dynamic Form Contract;
- Angular, React, Vue, Lit, Solid, Preact and Svelte integrations;
- complete renderers for Angular, Lit and plain HTML;
- Studio and code-generation tooling;
- Java and Rust contract parsers;
- renderer conformance and adversarial testing.
Not every part has reached the same maturity.
The core contract is currently the strongest part of the project. Some adapters, Studio and rendering capabilities are still evolving.
The project publishes known gaps instead of presenting every surface as complete.
That honesty matters because the architecture makes an ambitious promise:
One form contract should preserve its meaning wherever it is interpreted.
A promise like that should be difficult to earn.
The question I am still exploring
The technical question is no longer whether forms can be represented as data.
Many systems have already demonstrated that they can.
The more interesting question is this:
How much behavior can become portable data before the contract becomes more difficult than the application code it replaces?
My current answer is that the canonical contract can remain explicit and rigorous while the TypeScript authoring API becomes significantly smaller.
The user should write the concise form.
The engine should absorb the difficult parts:
- normalization;
- path resolution;
- capability checks;
- versioning;
- diagnostics;
- runtime limits;
- renderer conformance.
In other words:
Keep the public syntax small. Keep the internal contract complete.
Try it and challenge the design
Modyra is open source, and I am particularly interested in criticism from developers who have built:
- server-driven forms;
- schema-based interfaces;
- enterprise workflows;
- custom form builders;
- accessible component systems;
- multi-framework libraries.
You can explore the project here:
I would genuinely like to know:
- Is a portable form contract a problem you have encountered?
- Which part of this architecture feels essential?
- Which part feels unnecessarily complex?
- What would prevent you from using this approach?
I am the author, so I am obviously invested in the idea.
That is exactly why I am looking for arguments that can break it.
Top comments (0)