Objectory is a library for building nested object factories with strong typing. Use it to keep your test data builders consistent while still letting each test reshape the parts it cares about.
npm install -D @enormora/objectoryimport { createFactory } from '@enormora/objectory';
type Person = {
name: string;
age: number;
};
const personFactory = createFactory<Person>(() => {
return {
name: 'Jane Doe',
age: 32
};
});
const jane = personFactory.build();
const olderJane = personFactory.build({ age: 45 });
const crew = personFactory.buildList({ length: 3 });Objectory handles deeply nested factories, arrays, and targeted overrides so you can focus on the behaviours under test instead of wiring up objects.
Create a factory from a generator function. The generator returns the canonical shape for the objects you want to build.
const personFactory = createFactory<Person>(() => {
return {
name: 'Jane Doe',
age: 32
};
});Every object in the generator must come from a factory: createFactory() for a nested object, asArray() or a plain
array of factories for an array of objects. Anything else is rejected when building, so a type assertion that skips a
nested factory fails loudly instead of silently dropping defaults on partial overrides.
Create a factory for a union of object shapes from one factory per variant. The first entry is the variant it builds by default.
const httpRequestFactory = createUnionFactory([ getRequestFactory, postRequestFactory ]);createFactory rejects a union of object shapes and points here, because a factory built from a single generator has no
other variant to build, so a switch could only merge and leave the previous variant's keys behind. A property typed by a
union needs a union factory for the same reason. See Discriminated unions.
Always give createFactory its type argument. The shape is what the factory is for, and inferring it from the generator
gives narrower types than a fixture wants: a nested factory declared without one keeps its literal types, so its
properties stop accepting other values.
Build a single object, optionally overriding selected properties at any depth.
const adult = personFactory.build({ age: 21 });Overrides are plain values, never factories: use anotherFactory.build() or anotherFactory.buildList() when the
override values should come from another factory.
An override has to be a value the property's declared type allows. To merge into what the generator put there, it also has
to match its kind: a nested factory takes an object of overrides, an array property takes an array of them. A value the
type allows but the generator's kind does not, such as null for a Person | null property, replaces the generated value
instead. Anything else is a compile error, and a type assertion that gets around it fails loudly at build time rather than
being dropped.
Pass { freeze: true } as a second argument to deep-freeze the result, so a test that mutates a fixture fails at the
mutation instead of leaking state into a later assertion:
const frozenAdult = personFactory.build({ age: 21 }, { freeze: true });Build an array of identical instances by repeatedly calling build.
const passengers = personFactory.buildList({ length: 3 });When the length is a literal, the result is a tuple of exactly that length, so destructuring gives you defined elements
rather than Person | undefined:
const [ driver, guide ] = personFactory.buildList({ length: 2 });A length that is only known at runtime, or one above 64, gives readonly Person[] instead. buildList also accepts
freeze, which freezes the list and every element in it.
buildList returns built objects, not factories, so its result cannot be used as an array property inside a generator. Use
asArray or a plain array of factories there.
Expose the factory as an array factory so it can be embedded in other factories.
type Bus = {
passengers: readonly Person[];
};
const busFactory = createFactory<Bus>(() => ({
passengers: personFactory.asArray({ length: 2 })
}));
const bus = busFactory.build();Every element comes from the same factory, so every element is identical. When the elements should differ, use a plain array of factories instead. Its length is the number of elements:
const busFactory = createFactory<Bus>(() => ({
passengers: [
personFactory.withOverrides({ name: 'Jane' }),
personFactory.withOverrides({ name: 'Chris' })
]
}));Array elements must be factories. A plain array of already built objects is rejected, for the same reason a nested object must be a factory rather than a plain literal.
For a property typed as a fixed-length tuple, the length has to agree with the tuple:
type Wheels = { readonly wheels: readonly [Wheel, Wheel]; };
createFactory<Wheels>(() => ({ wheels: wheelFactory.asArray({ length: 2 }) })); // accepted
createFactory<Wheels>(() => ({ wheels: wheelFactory.asArray({ length: 3 }) })); // rejectedThe mismatch is reported as Type '3' is not assignable to type '2'. A length that is only known at runtime cannot be
checked, so it is rejected for a tuple property and accepted for an unbounded array property.
Create a new factory that always applies the given overrides before any ad-hoc overrides.
const namedFactory = personFactory.withOverrides({ name: 'Chris' });
const namedPerson = namedFactory.build();Derive a factory for a different type that adds fields the base type does not have.
type Employee = Person & {
employeeId: string;
};
const employeeFactory = personFactory.extend<Employee>(() => ({
employeeId: 'E-001'
}));
const employee = employeeFactory.build();extend needs the new type spelled out, because it is the whole point of the call. Naming the type the factory already
builds is rejected, because then only values change, which is what withOverrides is
for.
Reach for extend only when the type changes. Everything else is withOverrides.
withOverrides |
extend |
|
|---|---|---|
| Changes | values | the type, by adding fields |
| Result type | the same shape | the extended shape |
| Needs a type argument | no | yes |
| Can add a property the type does not have | no | yes |
| Layers with itself | later one wins | later one wins |
An extension may also give a base property a different default, but a value change is what withOverrides is for, so
prefer it. If both set the same property, the override wins whichever order they were applied in, because an
extension contributes a generator value and overrides are applied on top of whatever the generator produced:
baseFactory
.withOverrides({ label: 'from overrides' })
.extend<Extended>(() => ({ extra: 'x', label: 'from extension' }))
.build(); // label is 'from overrides'See Composing factories.
A path walks the built object, with a dot between segments and a number for an array index:
monitorFactory.buildInvalidWithout('request.url');
monitorFactory.buildInvalidWithChanged('request.retries', 'many');
monitorFactory.buildInvalidWithAdditional('request.timeout', 5);
feedFactory.buildInvalidWithChanged('articles.0.title', 42);A path that does not resolve throws, rather than returning the object unchanged, since a typo would otherwise leave the assertion testing nothing. The error names the whole path, not the part that failed:
Cannot resolve path "request.uurl"
buildInvalidWithout and buildInvalidWithChanged need the whole path to resolve. buildInvalidWithAdditional needs only
the parent to resolve, since its job is to add something new, so it accepts a leaf that does not exist yet at any depth and
rejects one that already does. For an array path it inserts at the index, which may equal the length but not exceed it.
Build an object with the property at path removed, useful for negative tests.
const missingName = personFactory.buildInvalidWithout('name');Build an object with the property at path replaced by value, even if it breaks the schema.
const invalidAge = personFactory.buildInvalidWithChanged('age', 'unknown');Build an object with an additional property added at path, useful for testing schemas that reject unknown fields. Throws if a property already exists at the given object path; for array paths, the value is splice-inserted at the given index.
const withExtra = personFactory.buildInvalidWithAdditional('nickname', 'Jay');What an override does depends on the kind of value the generator put in that property.
| Generator value | Plain override | Override array length |
|---|---|---|
Primitive, Date, function |
replaces it | not applicable |
| Nested factory | merges into the factory's defaults | not applicable |
asArray({ length }) |
merges into each element's defaults, per index | wins |
| Plain array of factories | merges into each element's defaults, per index | wins |
| Plain array of values | replaces per index | wins |
A property whose declared type is wider than the kind the generator picked is the exception: an override that fits one of the other members replaces the generated value rather than merging into it. See below.
Two consequences are worth knowing before you rely on them.
A merge can add keys, but it can never remove them. Overriding a nested factory with {} changes nothing, and
overriding a map-like nested factory with different keys yields the union of both. That is why a base factory should carry
the minimal shape that satisfies its type: then nothing ever needs removing. See
Composing factories. A union-typed value is the exception, because a union factory builds the
variant it switches to rather than merging onto the previous one. See
Discriminated unions.
An override has to be a value the declared type allows. undefined and null are accepted only where the property's
type spells them out, so build({ name: undefined }) on a required string is a compile error rather than a way to build
a value the type forbids. Use the buildInvalid* family when a test wants exactly that.
Where the type does allow it, such a value replaces what the generator put there instead of merging into it, whatever kind
of value that was. An explicit undefined keeps the key:
type Tour = {
guide: Person | null;
passengers: string | readonly Person[];
};
const tourFactory = createFactory<Tour>(() => ({
guide: personFactory,
passengers: personFactory.asArray({ length: 2 })
}));
tourFactory.build({ guide: null }); // { guide: null, passengers: [ … ] }
tourFactory.build({ passengers: 'nobody yet' }); // { guide: { … }, passengers: 'nobody yet' }Leaving the property out of the overrides is what asks for the default. Inside an override array, undefined at an
index keeps that element's default instead, which is what makes [ undefined, { name: 'Jane' } ] a way to override only
the second element.
An override array's length always wins, so an explicit [] empties the property:
busFactory.build({ passengers: [] }); // no passengers
busFactory.build({ passengers: [ { age: 20 } ] }); // exactly one passengerEarlier versions took the longer of the two lengths, so an explicit [] was a no-op and a test meaning to assert
something about an empty list quietly asserted it about a two-element list. If you relied on a short override array being
padded out with defaults, list the elements you want explicitly.
Compositional patterns built from the methods above.
type Passenger = {
name: string;
age: number;
};
const passengerFactory = createFactory<Passenger>(() => {
return {
name: 'Jane Doe',
age: 32
};
});
type Trip = {
driver: Passenger;
passengers: readonly Passenger[];
};
const tripFactory = createFactory<Trip>(() => {
return {
driver: passengerFactory,
passengers: passengerFactory.asArray({ length: 2 })
};
});
const trip = tripFactory.build({
driver: { name: 'Alex' },
passengers: [ { age: 40 } ]
});extend is for a new type that adds fields the base type does not have. Everything else is withOverrides.
A merge can add keys but never remove them, so give the base factory the minimal shape that satisfies its type: no
properties that are not required, and the smallest variant as a nested default. Then no test ever needs to remove
anything, and withOverrides is always enough.
type Product = {
name: string;
description: string;
discountedPrice?: number;
};
const productFactory = createFactory<Product>(() => ({
name: 'Widget',
description: ''
}));The generator leaves discountedPrice out, because nothing requires it. A test that cares about it asks for it, and one
that does not never sees the key:
const describedProductFactory = productFactory.withOverrides({ description: 'A very good widget' });
const discountedProductFactory = productFactory.withOverrides({ discountedPrice: 9 });
productFactory.build(); // { name: 'Widget', description: '' }
discountedProductFactory.build(); // { name: 'Widget', description: '', discountedPrice: 9 }extend is for the case where the type itself changes:
type Employee = Product & {
employeeId: string;
};
const employeeFactory = productFactory.extend<Employee>(() => ({
employeeId: 'E-001'
}));The minimal rule applies to nested factories too. Give the nested factory the empty shape and add to it, rather than giving it a full one and trying to take away:
type Translations = Readonly<Record<string, string>>;
type Listing = { title: Translations; };
const untranslatedTitleFactory = createFactory<Translations>(() => ({}));
const listingFactory = createFactory<Listing>(() => ({ title: untranslatedTitleFactory }));
listingFactory.build(); // { title: {} }
listingFactory.withOverrides({ title: { 'de-DE': 'Titel' } }).build(); // { title: { 'de-DE': 'Titel' } }Had titleFactory defaulted to { 'de-DE': 'Titel' }, no override could get back to {}, because a merge cannot remove
the key. That is the shape of every problem the minimal rule avoids.
For a union of object shapes, write one factory per variant and combine them with createUnionFactory. The first entry is
the variant it builds by default.
type CommonRequest = { url: string; retries: number; };
type GetRequest = CommonRequest & { method: 'GET'; };
type PostRequest = CommonRequest & { method: 'POST'; body: string; };
type HttpRequest = GetRequest | PostRequest;
const getRequestFactory = createFactory<GetRequest>(() => ({
method: 'GET',
url: 'https://example.com',
retries: 3
}));
const postRequestFactory = createFactory<PostRequest>(() => ({
method: 'POST',
url: 'https://example.com',
retries: 3,
body: '{}'
}));
const httpRequestFactory = createUnionFactory([ getRequestFactory, postRequestFactory ]);A property every variant has is overridable as usual. Naming the discriminator switches variant, and the target variant is built from its own factory, so nothing of the previous one is left behind:
httpRequestFactory.build();
// { method: 'GET', url: 'https://example.com', retries: 3 }
httpRequestFactory.build({ retries: 0 });
// { method: 'GET', url: 'https://example.com', retries: 0 }
httpRequestFactory.build({ method: 'POST' });
// { method: 'POST', url: 'https://example.com', retries: 3, body: '{}' }
httpRequestFactory.build({ method: 'POST', body: '{"a":1}' });
// { method: 'POST', url: 'https://example.com', retries: 3, body: '{"a":1}' }You only have to name the fields you care about. The rest come from the variant you switched to, not from the one you switched away from.
An override that does not say which variant it means is rejected, because nothing can decide it:
httpRequestFactory.build({ body: '{}' }); // rejected, which variant?
httpRequestFactory.build({ method: 'GET', body: '{}' }); // rejected, a GET has no body
httpRequestFactory.build({ method: 'PATCH' }); // rejected, no such variantTo share the fields common to every variant, build a factory for the common part and derive each variant with extend:
const commonRequestFactory = createFactory<CommonRequest>(() => ({
url: 'https://example.com',
retries: 3
}));
const getRequestFactory = commonRequestFactory.extend<GetRequest>(() => ({ method: 'GET' }));
const postRequestFactory = commonRequestFactory.extend<PostRequest>(() => ({
method: 'POST',
body: '{}'
}));A union factory works as a nested value and inside asArray, and the same rules apply:
type Monitor = { name: string; request: HttpRequest; };
const monitorFactory = createFactory<Monitor>(() => ({
name: 'monitor',
request: httpRequestFactory
}));
monitorFactory.build({ request: { method: 'POST' } });
// { name: 'monitor', request: { method: 'POST', url: 'https://example.com', retries: 3, body: '{}' } }A union-typed property needs a union factory, not one pinned to a single variant. Only a union factory knows the other
variants, so only it can honour a switch; a pinned one would have to merge and leave the previous variant's keys behind.
To pin a variant for a fixture, put it first in createUnionFactory so it is the default.
is missing the following properties from type 'ObjectoryFactory<...>': build, asArray, withOverrides, extend
A generator returned a plain object where a nested factory belongs. Every nested object is a factory, so wrap it:
createFactory<Car>(() => ({ driver: { name: 'Jane', age: 32 } })); // rejected
createFactory<Car>(() => ({ driver: personFactory })); // acceptedThe same message with Type 'Person' is missing ... inside a readonly ObjectoryFactory<...>[] means an array property
was given already built objects rather than factories. buildList produces built objects, so use asArray or a plain
array of factories.
not assignable to parameter of type '() => "objectory: this shape is a union of object types, use createUnionFactory() instead"'
createFactory was given a union of object shapes. Write one factory per variant and combine them with
createUnionFactory.
not assignable to parameter of type '() => "objectory: this is the shape the factory already builds, use withOverrides() instead"'
extend was given the type the base factory already builds, or no type argument at all, so the derived factory would
build the very same shape. Name a type that adds fields, or change values with
withOverrides.
Type '3' is not assignable to type '2' on an ArrayFactoryValue
asArray was given a length that does not match a fixed-length tuple property. The two numbers are the length you asked
for and the length the tuple has.
Invalid override at "…": a nested factory takes an object of overrides, received …
An override did not match the kind of value the generator put there, and the property's type does not allow it as a
replacement either. A nested factory takes an object, an array property takes an array. The path names the property,
counting from the object you called build on.
Invalid override for a union factory: no registered variant matches it
An override for a union factory named keys, or a discriminator value, that no registered variant has. Either name the
discriminator of a variant that is registered, or add the missing variant to createUnionFactory.
Objectory is heavily inspired by the excellent cooky-cutter and fishery libraries — thank you for paving the way for ergonomic test data builders.