The browser-side half of dyfields: the same declarative form schema the Go server compiles, compiled here, so a form can tell the user what is wrong before anything is submitted — with the same rules, the same error codes, and the same messages the server will produce.
Zero dependencies, ES modules, no Node built-ins in src/: it runs in a browser
as-is, and under Node 18+ for tests and SSR.
npm install @brobridge/dyfields
This page is the reference. USAGE.md is the walkthrough: one form followed from the first render to the second save — what to draw, what to recompute on a keystroke, what to POST, what a reopened form is holding, and what a save actually sends. Read that one first if you are wiring this into a UI.
And two working front ends are in
examples/ — the
same form drawn by React and by Vue from one schema, with no per-field code and
the outgoing payload shown as you type:
git clone https://github.com/BrobridgeOrg/dyfields
cd dyfields/examples/react && npm install && npm run dev # or examples/vue
Both are TypeScript and both consume this package the way you would — through
@brobridge/dyfields and its shipped declarations — so what they demonstrate is
the published surface, not a private one.
import { load } from '@brobridge/dyfields'
const compiled = load(await (await fetch('/api/forms/pipeline')).json())
// What should the renderer draw right now?
const { groups, fields } = compiled.visible(doc)
// Is what the user typed acceptable?
const { values, secrets, errors } = compiled.validate(doc)
if (errors.length === 0) await submit({ values, secrets })load() throws a SchemaErrorList if the schema itself does not make sense —
that is a bug in the schema, not in the user's input, so it fails loudly at
start-up rather than silently skipping a rule. Use validateSchema(input) when
you want the list of problems instead of an exception.
const doc = {
values: {
source_type: 'postgres',
sinks: [{ $id: 's_k1', type: 'kafka', topic: 'events' }],
},
secrets: {
'source_password': 'pg-secret',
'sinks.s_k1.auth_token': 'bearer-xyz',
},
}secrets is always flat and keyed by $id path, never nested inside values.
That is what makes "no secret ever sits in the value tree" a property you can
check at a glance instead of a convention you have to trust.
Because a secret key is built from $id, an entry that holds a secret anywhere
below it must carry a client-generated $id — compiled.requiresItemID(path)
tells you which lists those are, so the form can mint one when it adds a row.
Charset: [A-Za-z0-9_-]{1,64}.
| Call | What it is for |
|---|---|
load(input) |
parse + compile in one step |
parseSchema(input) / marshalSchema(s) |
wire format in and out; unknown keys are rejected |
compile(s) / tryCompile(s) |
check a parsed schema, with or without throwing |
validateSchema(input) |
every problem in a schema, never throws |
c.validate(doc) |
{ values, secrets, errors } — the cleaned document |
c.visible(doc) |
{ groups, fields } — booleans keyed by path |
c.apply(current, patch) |
merge a patch, then validate |
c.impactOf(current, patch) |
the paths a patch would clear, before they are gone |
c.redact(doc) |
{ values, secretsSet }, safe to log or hand out |
hasCode(errors, code) |
did any error carry this code |
validate() returns a new document: invisible fields are removed, defaults
for visible fields are filled in, and transient fields are dropped after
checking. A document that already conforms comes back unchanged.
const out = compiled.apply(current, {
values: { sinks: [{ $id: 's_h1', $deleted: true }] },
secrets: { 'source_password': '' }, // '' means "untouched"
})An absent key keeps its current value; $deleted removes an entry along with
every secret beneath it. For secrets specifically: absent keeps, '' keeps
(an untouched password input submits exactly that), null clears, any other
string sets.
readonly is enforced only in apply(), because rejecting a change needs a
baseline to compare against. validate() alone cannot see it. That is a
documented boundary — the server must be the one running apply().
The other three condition slots are refused outright: visible_when,
required_when and readonly_when may not read a transient field, because
they decide the shape of the document and the server would answer them without
the value. compile() reports that as transient_ref.
apply() also reports nothing about a transient field, or about a rule that
reads one. A transient value is dropped from the payload by design, so it is
never in a patch and never in the stored document: a server cannot check what it
was never sent. Those rules stay the form's to run, and compile() works out
which ones they are once, so no backend has to remember.
{ path: 'companions[1].guardian', field: 'guardian', group: 'party',
itemPath: 'companions.c_3b71', code: 'required',
reason: 'Guardian is required' }path uses the index, because that is what the table row is; itemPath uses
the $id, because that is what the secret key is built from. Every problem is
reported in one pass — a checker that returns only the first one makes the user
fix and resubmit as many times as they made mistakes.
Note the one naming difference from the wire format: results use camelCase
(itemPath, secretsSet), because they are JavaScript objects that never
travel back to the server. Schemas, documents and patches stay snake_case,
byte-for-byte what Go reads.
A field may declare mask, which changes nothing about what is validated or stored and only
says how redact() writes it:
const pub = compiled.redact(doc)
// { values: { national_id: '******6789', note: '***' }, secretsSet: [...] }A mask is 'all', 'omit' (which removes the key entirely), or an object saying how much
survives: { keep_head, keep_tail, char, width }. All of it behaves exactly as it does in Go,
against the same fixtures — a mask that masked differently in the browser than on the server
would put the log and the audit record in disagreement about what the user typed. See the root
README for the rules.
diff() is the other half, and the one a form should actually reach for:
const loaded = await (await fetch('/api/customer/42')).json() // a redacted document
// ... the user edits one field ...
const patch = compiled.diff(loaded, current)
await fetch('/api/customer/42', { method: 'PUT', body: JSON.stringify(patch) })The patch holds the difference and nothing else, so a masked field the user never touched is
not in the request at all — it drops out because it is equal to what was loaded, not because
of a rule about masks. Rows of an object_list are matched by $id: an untouched row
contributes nothing, an edited one contributes only its changed fields, a deleted one
contributes { $id, $deleted: true }. The server's Apply treats every absent key as
unchanged, so the values that stayed behind stay behind.
diff() never clears a secret on its own: a form never reads a secret back, so "I do not have
it" and "delete it" cannot be told apart from the values alone, and only one of the two can be
undone if the guess is wrong. Deleting a secret stays something the caller says outright.
That is the whole of the protection, and it is worth being plain about why there is no second
one: nothing marks a masked value as masked. '******6789' is a string like any other, so
neither apply() nor validate() can tell it from something the user typed. A form that
posts whole documents back will store the mask over the real value, and no rule on the server
can stop it. Sending a patch is not an optimisation here.
Note the signature: the standalone redact(c, doc) takes the compiled schema, because the
masks are read off it. It is the one function whose shape changed when masking landed; the
alternative was a redact(doc) that silently returned unmasked values.
index.d.ts is hand-written, because the library itself is plain JavaScript
that ships to a browser with no build step. Nothing about that arrangement
compares the declarations to the code, which is how Field.metadata came to be
declared Record<string, string> while a fixture had been carrying an array in
it since the day it was written.
npm test now closes both ways they can drift:
test/declarations.test.jschecks the names at runtime — every export ofsrc/index.jsagainst everyexport declare, and every wire interface against the key set the parser actually accepts.test/types/smoke.ts, typechecked bynpm run typecheck, checks the value types by holding thetestdata/fixtures to the declared interfaces and exercising the declared API surface.
Adding a field to the parser without declaring it, or declaring it as the wrong type, fails one of the two.
Both implementations run against the same fixtures in testdata/ at the
repository root, so they cannot drift apart without a test going red.
npm test
Known, deliberate differences:
- Regular expressions. Go uses RE2; JavaScript uses its own engine. Leading
inline flags (
(?i),(?m),(?s)) are translated, but a pattern that relies on backtracking will be accepted here and rejected by RE2 on the server. Keep patterns simple. - Format checks (
email,uri,hostname,ipv4,ipv6,uuid,duration,bytesize,cron,regex,date,time,datetime) are reimplemented against Go's standard-library behaviour, including the details that bite — leading zeros in IPv4 are rejected,::ffff:a.b.c.dis not IPv6,durationfollowstime.ParseDuration.emailis the loosest of these; a handful of exotic addressesmail.ParseAddressaccepts may be rejected here. - Decimal comparison is exact, via
BigInt, because adecimalfield that compares as a double loses cents. - No Builder DSL. The Go side has one for assembling schemas in code; on the browser side a schema arrives as JSON, so there is nothing to build.
The server remains the authority. This library exists to make the form honest while the user is still typing, not to replace the check that happens after they press submit.
Apache-2.0, same as the rest of the repository.