Skip to main content
Version: v0.1.8

How it works

The Cosyte parsers share a few rules: their models are frozen, their codes are stable, and their vendor behavior lives in profiles. @cosyte/synth keeps those rules, and adds the one that matters most for a generator: every value is synthetic by construction. This page explains each, and what it costs.

Synthetic by construction​

The risk with a fixture generator is a value that looks real enough to be taken for, or to match, a real person's data. So no code path emits a value from anywhere but a reserved range or a shipped pool of invented words: SSNs with a 900-series area, phone numbers in 555-0100 to 555-0199, example.* email domains, documentation IP ranges, a synthetic assigning authority for medical record numbers, and invented names, streets, and cities.

A parser is lenient about its input; synth is closed about its sources. A de-identifier shows that real PHI is gone from a document; synth shows that none was ever generated. The test suite's synthetic-safety properties check it for arbitrary seeds and every format:

import { createRng, isSyntheticPhone, isSyntheticSsn, safe } from "@cosyte/synth";

const rng = createRng(1);

[isSyntheticSsn(safe.ssn(rng)), isSyntheticPhone(safe.phone(rng))]; // => [true, true]

What it does and does not do cites the authority behind each source. Draw synthetic values shows how to use the providers in your own fixtures.

Built and checked by the parser​

synth never writes wire bytes by hand. Each format is built through its parser's own builder or serializer, so an artifact can be checked by feeding it back to that parser: it should parse with no warnings and serialize to the same bytes. The round-trip helpers do exactly that:

import { generateAdt, roundTrip } from "@cosyte/synth/hl7";

roundTrip(generateAdt({ seed: 3 })).specClean; // => true

What this cannot see is a misreading of a standard that the builder and the parser share, since the same code is on both sides of the check. For FHIR, a validator we did not write also grades the output; for the other five formats, no public validator that runs offline from a file was found. Who checks the output has the details.

Determinism: a seed gives the same bytes​

A seed decides the output. The generator is sfc32, seeded by splitmix32, with no dependencies. Math.random is banned from the source by lint, because its seed cannot be set, and nothing reads the clock: timestamps and control IDs come from the seed too. The generator is passed explicitly to every provider, never held globally, so two fixtures built in the same process do not affect each other.

CI checks this across Node.js versions: it generates a declared corpus of every format on Node.js 22, 24, and 26 in separate jobs, compares the digests, and compares them with a committed baseline. Determinism holds within a version window says what can change the bytes between releases, and how we say so.

Frozen results​

Generated artifacts, the Corpus and its manifest, the example code pools, and the fatal-code registry are frozen, so a fixture shared across tests cannot be changed by one of them. A write to one throws in strict mode.

Vendor quirks come from parser profiles​

A quirk fixture deviates from the standard in one way that a Cosyte parser's profile system describes as tolerable, and raises exactly one intended warning. Where a built-in public profile tolerates the deviation, the round trip also shows that profile suppressing or re-badging the warning. The quirk vocabulary is the parsers' own, so a quirk fixture tests documented behavior, not an invented failure. Generate vendor-quirk fixtures shows how.

Refusals are fatal, coded, and quote nothing​

A generator has no input to tolerate, so when it is asked for something it cannot build (an unknown message kind, a quirk outside a registry, a US Core profile it does not generate) it throws a SynthError with a stable code rather than return something mislabelled.

SynthError takes a code and nothing else. Its message comes from the frozen SYNTH_FATAL_MESSAGES table, so a value you passed cannot reach message, stack, or any field of the error. The cost is that an error names the rule that refused, not the value that tripped it; you already hold the value you passed, and the stack names the call. Fatal codes lists every code.