Public API
The package's . entry point — small on purpose, and scoped to using fabricator: every primitive is reached through T, not imported directly, and everything here either drives that loop (initialize, registry), supports it (Trace, Omitted, FabricatorError, Stream, sample, shuffle, Fabrication, ValueOf, satisfies, SatisfiedBy, layer, Layered, Config, Overlay, Context, Stack), or is what an ordinary .adapt(adapter, produce) call needs (Adapting). Extending fabricator is each its own entry point — @ghostry/fabricator/adapting for implementing a schema adapter, @ghostry/fabricator/harnessing for integrating with a test runner through @ghostry/harness, @ghostry/fabricator/internal for the structural tools an adapter needs.
initialize(config?)
function initialize(config?: {
types?: Registry;
salt?: Salt;
algorithm?: (seed: string) => () => number;
limits?: { combinatorial: number };
clock?: Date | "derived";
stack?: Stack;
}): Instance;Mints one isolated instance. types defaults to the built-in registry; salt defaults to empty if omitted (unless FABRICATOR_SALT supplies one — which pins the salt only, not clock); algorithm defaults to a built-in sfc32 generator. limits.combinatorial caps how many instances combinatorial(...) (below) may enumerate before throwing — defaults to 1024, checked eagerly at initialize() time, not on first call. clock is what T.date.past/T.date.future (and any producer reading its ProduceContext) resolve "now" against, and the default entropy for the instance: it defaults to the wall-clock instant of this initialize() call. Pass a Date to pin "now" to a specific instant, or clock: "derived" to derive "now" from the instance salt (an instant drawn across the entire representable Date range). See Reproducibility and Custom types.
new Fabricator(schema, { salt }) overrides the salt for one construction. Like every other option here it pins a single .trace slot and changes nothing else: the build still takes the next ordinal from the instance's construction counter and still inherits the instance's clock (see The clock is the entropy). new Fabricator(schema, { salt: layer(identity) }) pins the same slot but composes identity onto the instance's own salt instead of replacing it, so the construction still varies when the instance is re-salted — see layer(salt) below.
stack overrides the ambient carrier backing wrap, and is almost never worth setting. Left alone, the right one is chosen when the package is imported: every runtime with node:async_hooks gets an AsyncLocalStorage carrier whose frames survive await, and anything else gets a synchronous one. Supply your own — anything satisfying Stack — to bring async-capable wrap to a runtime that would otherwise fall back, or to force the synchronous carrier deliberately.
salt is one of these; new Fabricator(schema, options) accepts every slot of a captured Trace — salt, clock, path, kind, ordinal — so new Fabricator(schema, built.trace) replays that node. A given ordinal — including null, which combinatorial/coverage builds record in place of one — is taken verbatim and does not advance the instance's construction counter, which is what makes the replay exact. kind must match the schema or the constructor throws. A nested node's path is the base make extends for descendants, so replaying a nested object reproduces its subtree. See Reproducibility for the full trade-offs, including the cases that still need the parent (.refine() compute fields, recursive.self, .override() [Fixed] fields).
Instance
The shape initialize() returns:
T— the registry of type builders,types(or the default) as passedFabricator— a constructor:new Fabricator(schema)turns a Schema into a live Fabricatorsalt— this instance's salt, always an array; empty if you didn't supply one (and no env var did)combinatorial(schema)— every combination of every enumerable node inschema(every enum member, both sides of an optional field, and so on), as a lazy cartesian product. Throws eagerly, before producing anything, if the count would exceedlimits.combinatorial.coverage(schema)— the minimum set of instances such that every option of every enumerable node inschemaappears at least once — count equal to the widest single axis, not the product, with narrower axes cycling to fill it. Unbounded by design: its count can never exceed the schema as written, so unlikecombinatorialit carries no limit.fork(overlay?)— derives a new, relatedInstance, the ordinary way to vary configuration. SeeInstance.fork(overlay?)below.wrap(overlay, block)— makes a fork ambient for a block of code, for when the scope cannot reach the code that needs it. SeeInstance.wrap(overlay, block)below.root— the head of this instance's lineage, for identity. SeeInstance.rootbelow.context— what is in effect right now. SeeInstance.contextbelow.ancestry— this instance's position in its lineage, as an opaque chain of per-instance tokens. Exposed so a caller can reason about which frames an instance's calls resolve against; for "same lineage?", compareroot.
Both combinatorial and coverage return a lazy, re-iterable Iterable — safe to iterate more than once, each pass drawing fresh randomness for whatever the enumeration didn't pin.
Instance.fork(overlay?)
function fork(overlay?: Overlay): Instance;The ordinary way to vary configuration, and the one to reach for before wrap: a fork is a value, so every receiver means exactly itself and nothing depends on what is open around the call site.
Derives a new Instance laid over the one fork was called on: whatever overlay names overrides, whatever it omits inherits — salt, algorithm, types, limits, clock, all included. A fork is a full peer of an initialize() return value in every respect, including its own fork/wrap. A captured wall-clock or explicit Date is inherited as-is; an inherited "derived" clock re-derives from whichever salt the fork ends up with — see The clock is the entropy.
const base = initialize({ salt: "base" });
const A = base.fork({ salt: "A" });
A.salt; // ["A"] — replaced, the ordinary meaning of `salt`
const B = base.fork({ salt: layer("B") });
B.salt; // ["base", "B"] — composed insteadInstance.wrap(overlay, block)
function wrap<$Return>(
overlay: Overlay,
block: (scope: Instance) => $Return,
): $Return;Prefer fork unless the scope cannot reach the code that needs it — a callback you don't own, a deep call stack, or call sites already written against a destructured Fabricator. Inside a wrap, a receiver no longer tells you which configuration a call draws from; that is what a wrap is for, and the reason it is not the default recommendation.
fork(overlay), made ambient for the extent of block: every new Fabricator(...), combinatorial(...), and coverage(...) reached while block runs — on the instance wrap was called on, and on that instance's ancestors — resolves against the fork automatically, with nothing threaded through. block also receives the fork directly, as scope, for explicit use:
const { T, Fabricator, wrap } = initialize({ salt: "base" });
wrap({ salt: layer("a") }, (scope) => {
new Fabricator(T.number).fabricate(); // picks up the wrap automatically
new scope.Fabricator(T.number).fabricate(); // the same source, used explicitly
});The overlay lays over the instance wrap was called on, exactly as fork's does, whether or not a wrap is already open around the call. So a nested wrap accumulates when it is reached through the enclosing scope, and restates when it is reached through a receiver bound outside — which a destructured wrap always is:
wrap({ salt: layer("a") }, (scope) => {
scope.wrap({ salt: layer("b") }, (inner) => {
inner.salt; // [...base.salt, "a", "b"] — composed
});
wrap({ salt: layer("b") }, (inner) => {
inner.salt; // [...base.salt, "b"] — restated from the base instance
});
});To compose onto whatever is in effect without holding the enclosing scope, go through context.scope(). A bare (non-layered) salt replaces outright either way.
Which calls resolve against the frame: the instance wrap was called on, and that instance's ancestors up to the root — never a descendant (a fork of the receiver, or the wrap's own scope), a sibling, or another initialize(). See Ambience governs the receiver and its ancestors.
block may be async. On any runtime with node:async_hooks — Node, Bun, Deno — the ambient frame is carried by AsyncLocalStorage, so it survives await, and two concurrent wraps never see each other's configuration:
await wrap({ salt: layer("a") }, async () => {
await loadFixtures();
new Fabricator(T.number).fabricate(); // still the wrap's configuration
});Anywhere else — a browser bundle, or an initialize({ stack }) given a synchronous carrier — a frame cannot outlive the block's first await. Rather than let a later build resolve against the base instance unannounced, wrap throws SynchronousStackError as soon as it sees block return a promise. The check is on the block, not on what it does: it fires even if the block only ever touches scope, because whether something later reads the ambient frame is not knowable from wrap. Keep the block synchronous, or hand initialize({ stack }) an async-capable carrier.
See When you cannot thread the scope: wrap for the full walkthrough, including why fork is the better default choice.
Instance.root
readonly root: Instance;The instance at the head of this lineage — the one initialize() returned. A root's own root is itself, so it is never undefined and no caller has to handle absence.
Its job is identity. a.root === b.root answers "same lineage?", which fork/wrap descent preserves and which two separate initialize() calls never share, even when handed the same stack:
const base = initialize({ salt: "base" });
const A = base.fork({ salt: layer("A") });
A.root === base; // true
base.root === base; // true — a root names itself
initialize({ salt: "base" }).root === base; // falseIt is not a way to reach "the ambient instance" — every instance in a lineage resolves against the frames on its own line, so there is nothing to reach for. Nor is it the configuration to build against in preference to the one you hold: root's config is where the lineage started, not what is in effect now. For that, see context.scope().
Typed as Instance with an unparameterized registry. A root's registry is fixed at initialize and nothing later changes it — a fork({ types }) mints a new instance and leaves the root alone. What a descendant loses is the ability to name it: after such a fork its own $Registry is the fork's, so the root's is no longer recoverable from it. Recovering it would mean threading a second type parameter through fork and wrap, which is not worth it for an accessor whose job is identity — if you mean to build, you want the registry of the instance you hold. So root.T is untyped; root.Fabricator is unaffected, carrying no registry parameter.
This is a different situation from context.scope(), whose registry depends on which instance entered the innermost visible frame and so cannot be known statically at all.
Instance.context
readonly context: {
salt: readonly string[];
algorithm: (seed: string) => () => number;
clock: number;
depth: number;
scope(): Instance;
};What is in effect right now: the innermost wrap frame this instance can see, or the instance itself outside any. A live view, not a snapshot — a context reference held onto before a wrap still reflects it while active, and reverts once the wrap ends. clock is always a resolved epoch-millisecond number, even under "derived" — see The clock is the entropy.
scope() returns that configuration as a usable Instance — the frame's own scope, or this instance outside one. It is how to compose against whatever is active from a receiver bound elsewhere:
const { wrap, context } = initialize({ salt: "base" });
wrap({ salt: layer("a") }, () => {
context.scope().wrap({ salt: layer("b") }, (inner) => {
inner.salt; // [...base.salt, "a", "b"]
});
});Unlike rebuilding an overlay out of context.salt by hand, it carries types, limits, algorithm and clock across as well.
depth is how many wraps currently govern this instance, 0 outside any. A wrap's own scope is a descendant of the receiver, so its depth inside that wrap is 0 — it already holds the wrap's configuration as its own. A frame entered on a sibling or an ancestor does not count toward it.
The four value properties are getters, so destructure context itself, never those. Holding the object keeps the live view; pulling one out calls its getter once and freezes the result, as does spreading ({ ...context }):
const { context } = initialize({ salt: "base" }); // live
const { salt } = instance.context; // a snapshot, taken right nowscope is exempt, and is a function for exactly that reason. It is the one member you act through rather than read, so freezing it would not show up as an obviously stale value — an ambient build through the captured instance would still be correct while wrap/fork on it laid over the wrong base. Capturing a function captures the lookup instead, so this stays live:
const { scope } = instance.context;
wrap({ salt: layer("a") }, () => {
scope(); // the frame's scope, resolved now — not when it was destructured
});The Instance that scope() hands back is fixed, like any other instance. Holding that result across a frame change is a caller saying they wanted that one; holding scope keeps you live.
layer(salt)
function layer(salt: Salt): Layered;Tags a salt as composing onto whatever base is in effect, rather than replacing it outright — the reading a bare salt has everywhere else in this library. Works identically wherever a salt is accepted against a base: Instance.fork, Instance.wrap, and a single new Fabricator(schema, { salt }) call. See Composing instead of replacing: layer(...) for the full picture.
sample(list, stream) / shuffle(list, stream)
function sample<T>(list: ReadonlyArray<T>, stream: Stream): T;
function shuffle<T>(items: ReadonlyArray<T>, stream: Stream): T[];Helpers for a producer that already holds a Stream — .as(produce), T.opaque, a T.derive resolver — and needs to pick from a list it already has.
sample is a uniform pick, one stream.next() per call, and does not mutate list. An empty list throws a FabricatorError named EmptyItemsError. shuffle returns a new array in uniformly random order and does not mutate items; an empty list shuffles to an empty list.
Prefer T.enum when the pick can be represented as a schema: it is enumerable and keyed by path, so coverage/combinatorial see it and a salt replays it without a producer. See Drawing inside a producer.
registry
The default set of type builders, exported so it can be extended via registry.extend(({ T }) => ({ ... })) before being passed to initialize({ types }). See Custom types.
fabricator.trace
readonly trace: Trace;Every built Fabricator records how its stream is derived: the instance salt, the resolved clock this construction resolves "now" against (see The clock is the entropy), its structural path within that construction, its kind, and which construction on the source it belongs to. Recording is unconditional — a bare object or always still has a trace, so a nested node can be rebuilt with new Fabricator(schema, node.trace). Minting a stream from that trace is still paid only by nodes that draw. See Reproducibility.
Three values are not a function of the node's own stream, so replaying the node standalone does not reproduce them: a .refine() compute field (throws without the parent object), a recursive.self node (throws without the enclosing T.recursive), and an .override() [Fixed] field (replays the drawn value the parent discarded). Replay the parent.
Omitted
A sentinel value. Pass it to .override(...) or .fabricate(overrides) to force an omittable or optional field off.
FabricatorError
Every failure this library raises is an instance of this class — a schema-baked or per-call override rejected, a detached self reused outside its own T.recursive callback, an unrepresentable adaptation, and so on. Only the base class is exported; a specific failure is distinguished by .name, not by importing a subclass directly:
import { FabricatorError } from "@ghostry/fabricator";
try {
// `name` is a `T.string` field — a number violates its kind
Product.fabricate({ name: 5 as unknown as string });
} catch (e) {
if (e instanceof FabricatorError) {
// e.name, e.message — every subclass narrows the same way
}
}Stream (type only)
The parameter type T.opaque's producer receives — exported so a producer written as a named function has something to annotate its parameter with. See T.opaque.
Fabrication<$Fabricator> (type only)
Reads the value type a built Fabricator produces, straight off its fabricate signature:
import type { Fabrication } from "@ghostry/fabricator";
const Product = new Fabricator(ProductSchema);
type ProductValue = Fabrication<typeof Product>;
// same as: ReturnType<typeof Product.fabricate>
function seedDb(p: ProductValue) {
/* ... */
}ValueOf<$Schema> (type only)
The Schema-level counterpart of Fabrication — reads the value type a Schema will eventually produce, before it's built into a Fabricator. Useful for a helper that accepts a Schema directly:
import type { ValueOf } from "@ghostry/fabricator";
type ProductSchemaValue = ValueOf<typeof ProductSchema>;.satisfies<$Target>()
The chainable check that a schema produces a value assignable to a type that already exists elsewhere — an API response, a database model, a generated client. The type still comes from the schema; this is an optional check against a supplied T, not a second definition to keep in sync.
Assignable-to, not exact equality: extra fields pass. The method returns the same schema, unchanged at runtime, so it can sit inline. The target is not carried forward — a later .extend that changes a field's type is not re-checked. Put .satisfies last to check the final shape.
Call it on any schema, at any depth. A nested field that drifts errors on that field's line rather than on the outer object:
import { initialize, registry } from "@ghostry/fabricator";
import type { Order, Product } from "./api-client";
// Product = { id: string; price: number }
// Order = { id: string; product: Product; quantity: number; note?: string }
const { T, Fabricator } = initialize({ types: registry });
const id = T.string.whereby({ length: 8 });
const ProductSchema = T.object({
id,
price: T.number.whereby({ min: 1, max: 500 }),
}).satisfies<Product>();
const OrderSchema = T.object({
id,
product: T.object({ id, price: id }).satisfies<Product>(),
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~
// the error underlines the schema that is wrong:
// "schema produces": { id: string; price: string }
// "but target requires": Product
quantity: T.number.integer.whereby({ min: 1, max: 9 }),
});
const Orders = new Fabricator(OrderSchema);
Orders.fabricate(); // typed exactly as before — `.satisfies` changes nothing at runtimeBare builders are not schemas: T.string.satisfies<string>() is not a method; T.string.whereby({ length: { max: 8 } }).satisfies<string>() is.
Under exactOptionalPropertyTypes, T.optional produces note?: string | undefined (present, present-as-undefined, or omitted) and does not satisfy note?: string. T.omittable produces note?: string and does. Widen the target to note?: string | undefined, or use T.omittable.
satisfies<$Target>(buildable)
The function is available standalone for use anywhere you want to check the type. It is a type-level statement and a no-op at runtime. It also accepts a Schema, which is the form to use when the schema comes from somewhere you don't want to edit:
import { satisfies } from "@ghostry/fabricator";
const ProductSchema = T.object({
id,
price: T.number.whereby({ min: 1, max: 500 }),
});
const ProductFabricator = new Fabricator(ProductSchema);
satisfies<Product>(ProductFabricator);
satisfies<Product>(ProductSchema);If the type is not satisfied, a typecheck error is raised.
SatisfiedBy<$Target, $Buildable> (type only)
Resolves to true when the Schema or built Fabricator produces a value assignable to $Target, and otherwise to an object naming both sides. Assert it with the satisfies operator, no helper needed:
import type { SatisfiedBy } from "@ghostry/fabricator";
true satisfies SatisfiedBy<Product, typeof ProductSchema>;
true satisfies SatisfiedBy<Product, typeof ProductFabricator>;A failure reads Type 'boolean' does not satisfy the expected type '{ produces: { id: string }; required: Product }'.
Calling .adapt(adapter, produce)
Adapting<$Schema> — { schema, meta }, the parameter type of every kind's .adapt(adapter, produce) producer. Exported from ., not ./adapting, despite the name overlap: calling .adapt() to override one schema's mapping is an ordinary caller's business, not an adapter author's — the same reason Stream/ProduceContext are exported from . for .as(produce). meta is the kind's own config, reachable here without importing the Meta well-known symbol from ./internal.
const email = T.string.adapt(typebox, ({ meta }) =>
Type.String({ format: "email", maxLength: meta.whereby.length.max.value }),
);See Adapting to an external schema library for the full walkthrough.
@ghostry/fabricator/adapting
A separate entry point for authoring a schema adapter (e.g. @ghostry/fabricator-adapter-typebox-v0) — not re-exported from ., since ordinary schema composition never needs it. Named for the activity rather than the Adapter noun, the same pattern @ghostry/fabricator/harnessing follows for supplying a test-framework integration. This package names no external schema library and depends on none: every mapping, and every dependency it needs, belongs to the adapter.
Adapter<$Key, $Context, $Returnable>— the shape an adapter itself is:{ key, convert }.convertis the per-kind dispatch a conversion entry point (e.g.toTypeBox) calls.walk(adapter, schema, context)— walks a schema with an adapter, checking whether each node declared an adaptation for that adapter'skeybefore falling back to the adapter's ownconvert.Recurse<$Context, $Returnable>— the callbackwalkhands an adapter'sconvertso nested schema nodes (an object field, an array element) get the same adaptation lookup as the root.Adaptation— the well-known symbol a Schema stores its per-adapter overrides under; read only by an adapter.Adaptations/AdaptationsOf<$Schema>— the runtime shape of that map, and the type-level read of what a given Schema declared.
@ghostry/fabricator/harnessing
The entry point @ghostry/harness integrates through — not re-exported from ., since only a test setup module needs it. Neither package depends on the other: the types here are fabricator's own copy of the part of that contract it uses, satisfied structurally. See Harness for the setup.
integration(instance)— decorates an existing instance as an integration; it never mints one, so the caller'sinitialize(...)owns the configuration,clockespecially. Each test body runs insidecontext.scope().wrap({ salt: layer(...) }, ...), salted from that test'sIdentityas[kind, ...path, name, row?], and no clock is ever set. Overlaying the scope in effect rather than the instance it was handed is what lets the per-test salt compose onto an enclosingwrap— another integration's, say — instead of replacing it.Integration<$Context, $Established>—{ name, provides, frame }, whatintegration(...)returns.frameis a generator with one suspension point: the wrapper it yields encloses the test body and returns the body's value unchanged.Identity—{ kind, path, name, row }:"test"or"suite", the enclosingdescribenames outer → inner, the test name (""for a suite hook), and the.eachrow index orundefined. It carries no file.Frame<$Established>/Wrapper<$Established>— the generatorframereturns, and the optional value it yields.Wrapperis generic in its return and hands the body's value back unchanged, which is what keeps a synchronous test synchronous and lets frames nest.FrameArgs/ProviderArgs<$Established>— what each hook is handed, always as one object rather than positional arguments:{ identity }forframe, the same plusestablishedfor a provider.Provider<$Value, $Established>/Provides<$Context, $Established>— one provider per context key, each handed{ identity, established }, whereestablishedis whatever this integration's own wrapper passed forward;providesis the only source of an integration's keys.FabricatorTestContext<$Registry>—{ fabricator }, the per-test scopedInstancea test body receives.
Reading provides.fabricator outside that integration's own frame throws a FabricatorError named HarnessingProviderError. Only a composer breaking the contract does that; @ghostry/harness never does.
@ghostry/fabricator/internal
Another entry point — deliberately not re-exported from . — for adapter authors who need to dispatch on a primitive kind's structural shape directly (Kind, Meta, Buildable, Fabrication, and each kind's own Core type). If you're writing an adapter like the TypeBox one, this is where its dispatch tables come from; ordinary schema authoring never needs it.
