Braid

Type safety

How the builder accumulates types across the chain, exactly which misuses are compile errors, and why every one of them is checked again at runtime.

.main() and .join() don’t just validate their config at runtime: they also carry types through the chain, so a whole category of mistakes fails to compile rather than surfacing later as a wrong shape in the output.

How types accumulate

Braid carries six type parameters, threaded through every .main() and .join() call: the main row type, the main key type, the joined-properties type built up so far, the union of join names used so far, whether any source has made the result async, and the property name the main row is nested under (if any).

.main() fixes the first two (the row and key types) from the collection you pass it. Every .join() after that widens the joined-properties type with one more property (TJoined & { [name]: JoinValue<...> }) and adds its name to the union of names in use, without touching what earlier joins already established:

const builder = new Braid()
	.main({ source: products, key: (p) => p.sku })
	// TMain = Product, TKey = string, TJoined = {}

	.join({ name: "bigcommerce", source: bcProducts, on: (bc) => bc.sku, type: "single" });
	// TJoined = { bigcommerce: BcProduct | null }, TNames = "bigcommerce"

.run()’s return type is read straight off this accumulated state: the main row (spread across the top level, or nested under a property if .main() was given an as) intersected with every join’s property, flattened. So annotating a function’s return type never means restating every join by hand. See InferBraidRow for pulling that type off a configured builder directly.

The sixth parameter, the as name, matters beyond typing the output shape: it’s also what a duplicate-name check runs against. A join can’t be given the same name the main row is nested under, for the same reason two joins can’t share a name. See Reserved: the main row’s as name.

Compile errors

Four mistakes are the core set that fails to compile, plus one more that follows the same pattern once a main row has an as name to protect. Each is demonstrated in tests/types.test.ts, tests/braid.test.ts, or tests/compose.test.ts with a @ts-expect-error comment pinning the exact line that fails, and each also throws a BraidError at runtime. See Runtime re-checks for why both exist.

Duplicate join names

Reusing a name across two .join() calls on the same builder is a compile error. RejectDuplicateJoinName<TName, TUsed> intersects an impossible property onto the config type whenever TName is already in the accumulated name union, so the object literal you pass fails to satisfy the parameter type:

builder
	.join({ name: "bigcommerce", source: bcProducts, on: (bc) => bc.sku, type: "single" })
	// @ts-expect-error "bigcommerce" is already defined
	.join({ name: "bigcommerce", source: bcProducts, on: (bc) => bc.sku, type: "single" });

The check is deliberately attached to the config object as a whole rather than to name itself: putting it directly on name’s type reads better in theory, but it poisons inference for every other field on the same config, burying the real error under a pile of “not assignable to never” noise.

Mismatched key types

A join’s on extractor has to return the same key type the join matches against on the main side: the main collection’s key, or the join’s own key override if it has one. If it doesn’t, the mismatch is reported on on:

new Braid()
	.main({ source: products, key: (p) => p.sku }) // string
	.join({
		name: "variations",
		source: variations,
		// @ts-expect-error productId is a number, but the main key is a string
		on: (v) => v.productId,
		type: "many",
	});

This is why a join’s own key override matters for typing, not just for runtime matching: on’s expected return type is wrapped in NoInfer, so TypeScript infers the join’s key type from key alone and then checks on against it: a mismatched key re-types which key on has to agree with, rather than the two silently widening to a shared union.

Joining before a main collection exists

Calling .join() before .main() has been called can’t type-check. With no main collection yet, the builder’s key type is never, and a join’s key type defaults to the main key type when no key override is given, so on is required to return never, which no real function can do:

new Braid()
	.join({
		name: "bigcommerce",
		source: bcProducts,
		// @ts-expect-error there is no main collection yet, so no key type to match
		on: (bc) => bc.sku,
		type: "single",
	});

A second main collection

.main() declares its own this type as only accepting a builder whose main row type is still unset. Once .main() has been called, the builder’s this no longer matches that type, so a second call fails before it runs:

const builder = new Braid().main({ source: products, key: (p) => p.sku });

// @ts-expect-error a main collection is already defined
builder.main({ source: otherProducts, key: (p) => p.id });

Silently replacing the main collection on a second call would invalidate every join already configured against the first row type, so this is rejected outright rather than guessed at.

Reserved: the main row’s as name

If .main() was given an as, no .join() on the same builder can reuse that name: RejectMainAliasCollision<TName, TAs> applies the same phantom-property trick RejectDuplicateJoinName uses, just checked against the main row’s alias instead of the other join names:

new Braid()
	.main({ source: products, key: (p) => p.sku, as: "product" })
	// @ts-expect-error "product" is already the main alias
	.join({ name: "product", source: bcProducts, on: (bc) => bc.sku, type: "single" });

The reason is the same one duplicate join names get rejected for: the join would silently overwrite the nested main row on the output object instead of sitting alongside it.

What’s tracked but isn’t an error

Two other things the type system tracks are worth knowing, though neither one is a compile error: they change what a later expression is allowed to do rather than blocking the .join() call itself.

Defaults in the type

Passing an explicit default widens the join’s property type to include it. row.bigcommerce becomes BcProduct | "missing" if you pass default: "missing" as const, rather than the implicit BcProduct | null:

new Braid()
	.main({ source: products, key: (p) => p.sku })
	.join({ name: "bigcommerce", source: bcProducts, on: (bc) => bc.sku, type: "single", default: "missing" as const })
	.run();
// row.bigcommerce: BcProduct | "missing"

See Defaults and required joins for the runtime behavior behind the type.

Required narrows the type

required: true removes the default from the union entirely: a required single join is typed D rather than D | null, and a required many join is D[] with no alternate default, because by the time you have a row, a required join either matched or the call already threw:

new Braid()
	.main({ source: products.slice(0, 2), key: (p) => p.sku })
	.join({ name: "bigcommerce", source: bcProducts, on: (bc) => bc.sku, type: "single", required: true })
	.run();
// row.bigcommerce: BcProduct — never null

joinNames is typed too

builder.joinNames is typed as the union of names actually configured, not as a plain string[]:

const builder = new Braid()
	.main({ source: products, key: (p) => p.sku })
	.join({ name: "bigcommerce", source: bcProducts, on: (bc) => bc.sku, type: "single" })
	.join({ name: "shopify", source: shopifyProducts, on: (sp) => sp.sku, type: "single" });

type Names = (typeof builder.joinNames)[number];
// "bigcommerce" | "shopify"

That’s useful for building a lookup keyed on the joins you know exist, without a plain string losing the specific names at the type level.

Runtime re-checks

Every one of the compile errors above is checked again at runtime, and throws the same BraidError a genuine misconfiguration would:

  • A duplicate join name throws A join named "..." is already defined. Join names become properties on the output row, so they must be unique.
  • A second .main() throws A main collection is already defined. Call .main() once per Braid instance.
  • A join name colliding with the main row’s as throws The main collection is already attached as "...", so a join can't use that name too.

That duplication is intentional rather than incomplete coverage. Types are erased at build time: the compiled code that actually runs has no idea TJoinKey or RejectDuplicateJoinName ever existed, and data arrives from the network, config files, or any-typed layers the compiler never saw. A mismatched key type between two platforms’ JSON payloads, for instance, is invisible to the compiler the moment either side is typed loosely; see Key equality and nullish keys for exactly that failure mode. The compile-time checks catch mistakes as you write the braid; the runtime checks catch the same mistakes (and the ones the compiler structurally can’t see) once the code is actually running.

Next steps