Build an object piece by piece, know its exact shape at every step, and prove when it is complete.
Incremental is a typed fold over object contributions. Each contribution
declares what it provides, what it requires, and how it combines. You
fold them together; the type system tracks exactly what has been constructed;
and exhaustive turns that knowledge into a completeness proof.
Part<T> requires some keys provides some keys
build folds parts left → right
the type state grows
exhaustive proves every required key exists
Why not object spread? Because spread throws away the interesting facts:
- a non-exhaustive build knows its exact shape — not
Partial<T>; - duplicate contributions fail by default;
- a part that needs a key cannot run before something provides it;
- a replacement must target a key that already exists;
- conditional contributions never satisfy exhaustiveness.
npm install @doeixd/incrementalimport { Incremental } from "@doeixd/incremental";
interface Config {
host: string;
port: number;
secure?: boolean;
url: string;
}
const I = Incremental.make<Config>();
const config = I.build(
I.with.host("localhost"),
I.with.port(3000),
I.partial({ secure: true }),
I.derive(["host", "port", "secure"], ({ host, port, secure }) => ({
url: `${secure ? "https" : "http"}://${host}:${port}`,
})),
I.exhaustive,
);
// ^? Configbuild reports exactly what you constructed. It does not pretend the result
is a Partial<T>:
const partial = I.build(I.with.host("localhost"), I.with.port(3000));
// ^? { host: string; port: number }
partial.host; // string
partial.port; // number
partial.url; // ✗ compile error: url was never constructedI.exhaustive additionally proves the result is a complete T. Because it
returns T & State, optional properties you supplied stay known to be present:
const config = I.build(
I.with.host("localhost"),
I.with.port(3000),
I.with.secure(true),
I.derive(["host", "port", "secure"], ({ host, port, secure }) => ({
url: `${secure ? "https" : "http"}://${host}:${port}`,
})),
I.exhaustive,
);
config.secure; // boolean — not boolean | undefined| API | Provides | Requires | Policy |
|---|---|---|---|
I.field(key, value) |
key |
— | add |
I.with.key(value) |
key |
— | add |
I.partial({ ... }) |
each provided key | — | add |
I.partial(() => ({...})) |
each provided key | — | add |
I.derive([keys], f) |
keys returned by f |
keys |
add |
I.override(key, value) |
key |
key |
replace |
I.update(key, f) |
key |
key |
replace |
I.default(key, value) |
key |
— | default |
I.defaults({ ... }) |
each provided key | — | default |
I.when(condition, part) |
conditionally | part's | part's |
- add — the key must not exist yet.
- replace — the key must already exist.
- default — set the key only if it is absent (and the key is guaranteed).
Both styles produce and consume the same first-class Part values.
Composable — best when independent modules each export a part:
const routingPart = I.partial({ host: "router" });
const remotePart = I.partial({ port: 7000 });
const derivedPart = I.derive(["host", "port"], ({ host, port }) => ({
url: `${host}:${port}`,
}));
const result = I.build(routingPart, remotePart, derivedPart, I.exhaustive);Chained — best for one cohesive constructor. Method chaining gives
TypeScript a real sequential inference boundary, so derive sees exactly what
came before, with no dependency list:
const config = I.begin()
.field("host", "localhost")
.field("port", 3000)
.derive(({ host, port }) => ({ url: `http://${host}:${port}` }))
.exhaustive();Use .use(part) to drop a reusable part into a chain.
Conditionals
I.when(condition, part) applies a part conditionally. Added keys become
optional, so a conditional contribution never satisfies exhaustiveness:
const result = I.build(I.when(isDev, I.with.debug(true)));
// ^? { debug?: boolean }
I.build(I.when(isDev, I.with.debug(true)), I.exhaustive);
// ~~~~~~~~~~~ ✗ debug is not guaranteedA conditional replacement keeps its key guaranteed, because the key exists before and after:
I.build(I.with.debug(false), I.when(isDev, I.override("debug", true)));
// ^? { debug: boolean }Defaults and config merging
A default sets a key only if it is absent, and guarantees the key. This is the config-merge case in a single pass:
const config = I.build(
I.defaults({ host: "localhost", port: 3000, secure: false }),
I.override("port", 8080),
I.derive(["host", "port", "secure"], ({ host, port, secure }) => ({
url: `${secure ? "https" : "http"}://${host}:${port}`,
})),
I.exhaustive,
);default / defaults are also available on the chain.
What a part actually provides
A contribution only provides keys its type guarantees are present. This is
what keeps exhaustive honest.
| Input | Provides |
|---|---|
I.partial({ host: "x" }) |
host |
I.partial(v) where v: Partial<T> |
nothing — the keys may be absent |
I.field(k, v) where k: keyof T (a union) |
one of the keys, none guaranteed |
I.derive([...], f) |
the keys f definitely returns |
I.default(...) / I.defaults(...) |
the keys, guaranteed |
I.when(cond, part) |
optional versions of the keys |
A guaranteed value must also be assignable to the stored type, so an optional
key cannot be set to undefined, and a required key cannot be set to a
possibly-undefined value.
Ordering and dependencies
The build is a fold, so it imposes a total order: dependencies must appear earlier in the argument list, i.e. a topological order.
I.build(
configPart, // provides `config`
loggingPart, // needs `config`
I.exhaustive,
);Reversing them fails with UnsatisfiedDependencyError<"config">. Think of
derive([...needs], ...) as declaring a topological edge, not as general
dependency injection.
Full API reference
Creates a builder for target type T. The type parameter is purely static.
Folds parts left to right. With I.exhaustive as the final argument, the result
is proven to satisfy T; otherwise the result is the exact inferred state.
Starts a chained builder:
I.begin()
.field(key, value)
.partial({ ... })
.derive((current) => ({ ... }))
.default(key, value)
.defaults({ ... })
.use(part)
.override(key, value)
.update(key, (current) => next)
.when(condition, part)
.build(); // finalize without a completeness proof
.exhaustive(); // only callable once every required key existsfieldis the canonical setter and works with dynamic keys;with.*is namespaced sugar backed by aProxy.partialaccepts an object or a lazy factory. It rejects keys outsideT.derivereceives only the keys it declared.override/updatefail unless the key has already been established.whenand the lazypartialform are@experimental.
In a Foldkit update, a reset should produce a fresh Model:
interface Model {
count: number;
step: number;
history: number[];
canUndo: boolean;
label: string;
}Before — changing the three source fields leaves the old derived fields in place. The spread still has the right TypeScript shape:
ClickedReset: ({ model }) => ({
model: { ...model, count: 0, step: 1, history: [] },
// `canUndo` and `label` may still describe the previous count.
}),After, chained builder — construct the reset from scratch. derive sees the
fields already built:
import { Incremental } from "@doeixd/incremental";
const ModelI = Incremental.make<Model>();
ClickedReset: () => ({
model: ModelI.begin()
.field("count", 0)
.field("step", 1)
.field("history", [])
.derive(({ count, history }) => ({
canUndo: history.length > 0,
label: `Count: ${count}`,
}))
.exhaustive(),
}),After, composable parts — the same reset declares its dependencies explicitly:
ClickedReset: () => ({
model: ModelI.build(
ModelI.with.count(0),
ModelI.with.step(1),
ModelI.with.history([]),
ModelI.derive(["count", "history"], ({ count, history }) => ({
canUndo: history.length > 0,
label: `Count: ${count}`,
})),
ModelI.exhaustive,
),
}),Both forms require every Model field at exhaustive.
Adding a required field to Model now makes the reset fail to compile until it
provides that field. A small update to an existing state can still use the usual
update helper.
An order message needs a subtotal, tax, and total. These values form a sequence: each calculation needs a field produced earlier.
interface OrderPayload {
items: ReadonlyArray<{ price: number; quantity: number }>;
subtotal: number;
tax: number;
total: number;
}
const sumItems = (items: OrderPayload["items"]) =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0);Before — an object literal allows the arithmetic to disagree with itself:
const subtotal = sumItems(items);
const payload: OrderPayload = {
items,
subtotal,
tax: Math.round(subtotal * taxRate),
total: subtotal, // TypeScript accepts this stale total.
};After, chained builder — each step reads the constructed state:
const OrderI = Incremental.make<OrderPayload>();
const payload = OrderI.begin()
.field("items", items)
.derive(({ items }) => ({ subtotal: sumItems(items) }))
.derive(({ subtotal }) => ({ tax: Math.round(subtotal * taxRate) }))
.derive(({ subtotal, tax }) => ({ total: subtotal + tax }))
.exhaustive();
Message.SubmittedOrder(payload);After, composable parts — each part names the fields it needs, so it can be defined separately and folded into the payload later:
const subtotalPart = OrderI.derive(["items"], ({ items }) => ({
subtotal: sumItems(items),
}));
const taxPart = OrderI.derive(["subtotal"], ({ subtotal }) => ({
tax: Math.round(subtotal * taxRate),
}));
const totalPart = OrderI.derive(["subtotal", "tax"], ({ subtotal, tax }) => ({
total: subtotal + tax,
}));
const payload = OrderI.build(
OrderI.with.items(items),
subtotalPart,
taxPart,
totalPart,
OrderI.exhaustive,
);
Message.SubmittedOrder(payload);Both forms make the dependency order visible and produce a complete
OrderPayload. The formulas themselves still need to be correct.
Say routing and checkout each export a handlers object. The app has an
independent list of the handlers it expects:
interface Handlers {
Navigated: (path: string) => void;
Submitted: (orderId: string) => void;
Cancelled: (orderId: string) => void;
}Before — a later spread silently replaces an earlier tag. A missing tag can also go unnoticed when the target type is inferred from the spread:
const handlers = { ...Routing.handlers, ...Checkout.handlers };After, composable parts — each feature contributes its own part:
import { Incremental } from "@doeixd/incremental";
const H = Incremental.make<Handlers>();
const handlers = H.build(H.partial(Routing.handlers), H.partial(Checkout.handlers), H.exhaustive);After, chained builder — the same modules can be added in sequence:
const handlers = H.begin().partial(Routing.handlers).partial(Checkout.handlers).exhaustive();In either form, if both modules provide Submitted, the second contribution reports
DuplicateContributionError<"Submitted">. If neither provides Cancelled,
exhaustive reports MissingKeysError<"Cancelled">.
Invalid builds report a named diagnostic at the offending argument, rather
than an opaque never:
MissingKeysError<"url">
DuplicateContributionError<"host">
UnsatisfiedDependencyError<"config">
ExtraKeysError<"banana">
See docs/diagnostics.md for the full list and fixes.
- Finite product types only. Index signatures (
Record<string, T>) are rejected withUnsupportedTargetError; exhaustiveness is meaningless for infinitely many keys. - Discriminated unions are out of scope for now — see
docs/variants.mdfor the composition pattern. - Statically known parts. Spread a tuple; a mutable array must be asserted
as const. - Contributions whose types do not guarantee their keys are handled
conservatively and can never satisfy
exhaustive. anyvalues are not defended against (as always).
- npm: https://www.npmjs.com/package/@doeixd/incremental
- GitHub: https://github.com/doeixd/incremental
- Issues: https://github.com/doeixd/incremental/issues
vp install # install dependencies
vp test # runtime tests
vp check # format, lint, type check
vp run typecheck # type-check only
vp run build # build the libraryType-level assertions live in tests/types.ts, tests/edge-cases.ts and
tests/diagnostics.ts. They are type-checked but never executed, so
@ts-expect-error cases can describe invalid builds safely.