Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/auto-builder-factories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@cerios/cerios-builder": minor
---

Add `CeriosAutoBuilder<T>()` and `CeriosClassAutoBuilder(Class)` base-class factories that give a subclass an automatic bare-name setter for every property — no more hand-written `setProperty` wrappers. Custom logic stays as ordinary class methods (their return type can be inferred), and mixes freely with the auto setters while keeping the existing compile-time required-property tracking: calling `build()` before all required properties are set is a compiler error. Reserved names (e.g. a `build` property) get a `Prop`-suffixed setter, non-identifier keys are set via bracket access, and the full build-variant/validation/`clone`/`from` API is inherited unchanged.

Adds nested-builder composition to both auto builders. `BuilderComposerFromFactory` and `ClassBuilderComposerFromFactory` now accept factories from `CeriosAutoBuilder` / `CeriosClassAutoBuilder` subclasses, not just hand-written ones, so a custom method can take a callback, hand it a preconfigured child builder, and store the result — with the callback refusing to compile unless it returns a fully-set child builder of the right type. Previously these types resolved to `never` for auto builders, because an auto builder's declared instance type is structural and is not assignable to the nominal `CeriosBuilder`/`CeriosClassBuilder`.

Adds `BuilderWith<Builder, Keys>` and `ClassBuilderWith<Builder, Keys>` for writing explicit return types, which matters if your lint rules require them. The built type is derived from the builder, so these take two arguments instead of the three needed by `BuilderStep`/`BuilderPreset`: `static createWithDefaults(): BuilderWith<AddressBuilder, "country">` and `inRotterdam(): BuilderWith<this, "city">`. They work for hand-written builders too. Use `this` (not the class name) in instance methods so previously set keys are preserved, and name root keys only — setting a nested value brands its root property.

Both factories now expose one identical constructor shape, `new Builder(data, { requiredFields, validators })`. Naming the options removes a trap: the two base builders take those two arguments in opposite positional order, and the class auto builder previously had no supported way to pass either through `super()` at all. `requiredFields` additionally accepts an exhaustive record (`{ id: true, name: true }`) that the compiler forces you to keep complete — the array form is checked for validity but not completeness, so adding a required property to your type would otherwise leave runtime validation silently behind.

The auto-builder API deliberately exposes **only** the generated root setters: `setProperty`, `setProperties`, `setNestedProperty`, and `addToArrayProperty` are not part of it. Finer-grained updates (nested values, appending to arrays) belong in a distinctly-named custom method that delegates to a root setter, or in a director that composes multiple builders — see the Director pattern section in the README.

Fixes a class of silent state corruption in both auto builders. The proxy treated any name reachable on the prototype chain as a real member, but only 22 names were declared reserved, so a modelled property named `setProperty`, `createBuilder`, `hasOwnProperty` (and about a dozen others) type-checked as a bare setter and then invoked the real method instead — `builder.createBuilder("x")` replaced the entire builder state with the string `"x"`. All such names now route through the `*Prop` suffix like any other reserved name, and a test walks both prototype chains to fail the build if a member is ever added without being reserved.

Copy-on-write no longer runs the subclass constructor. Every setter re-creates the builder, which previously meant calling `new this.constructor(...)` with an internal argument shape that a user-written `constructor(data?)` cannot receive — silently dropping validators and required fields on the class side, and reverting `setRequiredFields()` on the object side.

Also fixes `DataPropertiesOnly` in the class builders so methods **with parameters** are correctly excluded from the compile-time required-property check (previously only zero-argument methods were stripped under `strictFunctionTypes`, which could make a class with a parameterized method impossible to `build()`).

Copy-on-write carries a subclass's own instance fields across. Because it bypasses the subclass constructor, a field like `private cache = new Map()` was dropped on the first setter call — and since the name was then absent from the proxy target, the proxy handed back an auto-setter _function_ for it, so `this.cache.set(...)` failed with a `TypeError` naming `.set`. Truthiness checks silently took the wrong branch.

Passing a bare required-fields record positionally — `new B({}, { name: true })` instead of `new B({}, { requiredFields: { name: true } })` — now throws. It structurally matches the options object, so it was previously accepted and silently produced a builder with **zero** required fields, meaning runtime validation passed for every incomplete object.

`__proto__` is no longer a reserved builder name, so no `__proto__Prop` setter is generated. That setter mapped straight back to the real `__proto__` key; on the class builder the resulting assignment re-parented the built instance, which silently stopped being an instance of its own class and lost every method.

`CeriosClassAutoBuilder(Person)` returns one memoised runtime class per target class — it previously minted a new class on every call — so `instanceof` holds across separate factory calls. The deprecated `requiredDataProperties` static is no longer part of the returned constructor type — with the runtime class now shared, writing it there would have been visible to every other consumer of that class. Declaring the static on your own subclass is unaffected.

**Changes affecting existing (non-auto) builders:**

- `buildFrozen()` and `buildSealed()` no longer freeze and return the builder's own internal state. They returned `this._actual` directly, so two calls yielded the same object, mutating a nested field of the result mutated the builder, and a `buildSealed()` after a `buildFrozen()` came back frozen rather than sealed. The deep variants were already correct.
- `CeriosClassBuilder` no longer aliases its internal state into the instance it builds. Mutating a built instance's nested object changed the builder, and `buildDeepFrozen()` froze the builder's own state permanently, so every later build from it silently dropped writes.
- Deep clone preserves prototypes, so a nested class instance stays an instance of its class instead of being flattened to a plain object and losing its methods.
- `setProperty`, `setProperties`, and `addToArrayProperty` reject `__proto__` as a key. `setNestedProperty` already rejected it inside a path; a plain key was unguarded. `constructor` and `prototype` remain settable as ordinary keys — only path traversal through them is blocked.
- `CeriosBuilder.from()` returns a branded, immediately buildable builder, matching the auto builders. Seeding from a complete object no longer forces a `buildUnsafe()` fallback. Note it requires the subclass to declare a public constructor.
- `setNestedProperty` no longer re-clones each intermediate level of the path, which made its cost O(depth x size) on state that was already freshly cloned.

- Deep clone now preserves `Date`, `Map`, `Set`, and `RegExp` (each previously cloned to `{}`, so a `Date` in seed data was silently destroyed) and handles circular references (which previously threw `RangeError: Maximum call stack size exceeded` from `from()`, `clone()`, and `buildDeepFrozen()`). One implementation replaces five near-identical private copies; `deepFreeze`/`deepSeal` likewise collapse from four copies to one, and are cycle-safe.
- Build variants no longer hand out the builder's live internal state. `build()` and `buildUnsafe()` returned `this._actual` by reference, so mutating the result mutated the builder and everything built from it afterwards; `buildFrozen()` called `Object.freeze` on the builder's own state. `buildPartial()` is now a deep copy, which is what its documentation already claimed.
- `setNestedProperty()` rejects paths containing `__proto__`, `constructor`, or `prototype` instead of writing through a prototype and silently swallowing the value.
- Validation errors are now `CeriosBuilderError`, an `Error` subclass carrying `missingFields` and `validationErrors` arrays so callers can stop string-matching. Existing message text is unchanged apart from the two items below.
- A validator returning `false` is now identified by index and function name. Three failing validators previously produced `"Validation failed: Validation failed; Validation failed; Validation failed"`, with no way to tell which failed. A validator returning `undefined` — easy to write by forgetting a `return` — was treated as a **pass** and is now an error, as is an empty-string return.
- The "Please set these fields before calling build." advice now names the variant actually called, so `buildDeepSealed()` no longer points at `build()`.

- `CeriosClassBuilder.clearOptionalProperties()` now preserves the root object of a nested required path. A required path like `"address.city"` was matched as a single literal key, found nothing, and discarded `address` entirely — destroying the data the call was meant to keep. `CeriosBuilder` already handled this correctly; both now share one implementation.
- `CeriosBuilder.buildPartial()` returns a copy instead of the live internal state, so callers can no longer mutate a builder through it. This matches `CeriosClassBuilder.buildPartial()`.
- `CeriosBuilder.setRequiredFields()` is now copy-on-write and returns a new builder rather than mutating and returning `this`. This matches `CeriosClassBuilder.setRequiredFields()` and every other method on both classes. Fluent chains are unaffected; only code holding the pre-call reference and expecting it to have changed will see a difference.
11 changes: 11 additions & 0 deletions .changeset/base-auto-builder-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@cerios/cerios-builder": minor
---

Add first-class support for base types and base classes to both auto builders.

Building a derived target already worked — setters are generated from `keyof T`, which includes inherited members, and `CeriosClassAutoBuilder` runs the real constructor chain — and is now covered by tests for inherited data properties, `instanceof` both classes, inherited methods, required fields on inherited properties, `from()`, and compile-time gating.

What was missing is the deprecated builders' shared base builder (`abstract class BaseXBuilder<T extends Base> extends CeriosBuilder<T>`), whose literal port is illegal TypeScript for a factory-based auto builder. Its replacement is the **base-builder function**: shared custom methods are written once in a function constrained on the new `AutoBuilderBase<TBase>` (`ClassAutoBuilderBase<TBase>` for classes, where TBase may be abstract) and applied on top of each concrete auto builder, returned as the new `BuilderExtension<TBuilder, TShared>` so the derived builder keeps its full setter set, statics, and per-builder required fields. Setter calls inside shared methods brand the derived builder's compile-time build gate like any direct setter call.

See the README section "Base Types and Shared Builder Logic" and MIGRATION.md section 7 ("Generic base builder classes → base-builder functions").
7 changes: 7 additions & 0 deletions .changeset/deprecate-standard-builders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cerios/cerios-builder": minor
---

**Deprecate `CeriosBuilder` and `CeriosClassBuilder`.** Both hand-written builder base classes are deprecated and will be **removed in the next major version**. Migrate to `CeriosAutoBuilder<T>()` / `CeriosClassAutoBuilder(Class)`, which generate every setter automatically with the same compile-time required-property tracking — see MIGRATION.md for a per-feature before/after guide. The `setNestedProperty` dot-path feature is intentionally not carried over: compose nested objects with a director that coordinates multiple builders (or with nested-builder callbacks via `BuilderComposerFromFactory`) instead.

**An all-optional type can now `build()` immediately.** The compile-time gate on `build()` and the other validated build variants dissolves when the target type has no required properties, so a builder for an all-optional type no longer needs a throwaway setter call (or a `buildUnsafe()` fallback) before building. This applies to all four builder base classes and is exposed as the `BuildGate` / `ClassBuildGate` types.
19 changes: 19 additions & 0 deletions .changeset/getter-only-accessor-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@cerios/cerios-builder": patch
---

Fix getter-only class accessors breaking the class builders.

A getter-only accessor (`get displayName() { ... }`) is structurally indistinguishable from a data property, so `CeriosClassAutoBuilder` generated a setter for it and the build step later ran into `TypeError: Cannot set property displayName ... which has only a getter` — a confusing failure far from the misuse. Worse, the compile-time build gate _demanded_ the accessor be set, making such classes unbuildable through `build()`.

Now:

- `CeriosClassAutoBuilder` generates setters for **writable public data properties only**. Methods, getter-only accessors, and `readonly` fields get no setter and don't count toward the build gate — a class owns its `readonly` fields through its own constructor, so seed them with `new Builder({ id: "1" })` or `Builder.from(instance)`. This makes setting a getter-only accessor a **compile error**, on top of the runtime guard below. Accessors with both a getter and a setter are writable and get a setter as normal; a derived class that redeclares an inherited getter-only accessor as a getter/setter pair is writable again.
- A runtime guard throws a `CeriosBuilderError` naming the accessor and the class when a getter-only accessor is written through any path that erases the `readonly` type — untyped/`any` access, `setProperty`/`setProperties`, a `setNestedProperty` root, `addToArrayProperty`, or constructor seed data — replacing the former `TypeError: Cannot set property ... which has only a getter` at build time. This guard also covers the deprecated `CeriosClassBuilder`.
- `ClassBuildGate` only demands **writable** required data properties, so classes with getter-only accessors or `readonly` fields are buildable through `build()`; enforce a genuinely required `readonly` field with runtime `requiredFields`.
- The post-construction assignment inside build skips non-writable keys instead of tripping over them.
- New `WritableKeys<T>` helper type exported.

Note: `CeriosAutoBuilder` (plain object types) deliberately keeps setters for `readonly` properties — an interface has no constructor, so the builder is the only construction path, and the built object stays `readonly`-typed.

Also documents and locks with tests the previously unspecified edge cases: symbol-keyed properties are outside the builder's model (no setter, dropped by state snapshots), private/protected class members get no setters, and builder subclass fields are carried by reference across copy-on-write forks.
5 changes: 5 additions & 0 deletions .changeset/packaging-dual-esm-cjs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cerios/cerios-builder": patch
---

The published package now ships explicit dual CJS/ESM artifacts with per-condition type declarations. The build tool moved from tsup to tsdown, and the dist filenames changed: `dist/index.js` → `dist/index.cjs`, `dist/index.d.ts` → `dist/index.d.cts`, with `dist/index.d.mts` added alongside `dist/index.mjs`. The `exports` map now declares a `types` condition per branch (`.d.mts` for `import`, `.d.cts` for `require`/`default`), so TypeScript resolves the matching declaration format under every module resolution mode; the layout is validated with `@arethetypeswrong/cli` in CI. Importing the package entry point (`import`/`require` of `@cerios/cerios-builder`) is unaffected — only code deep-importing dist filenames directly would notice.
11 changes: 11 additions & 0 deletions .changeset/richer-base-builder-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@cerios/cerios-builder": minor
---

Expose more of the build/state API to shared methods in base-builder functions.

The `AutoBuilderBase<TBase>` / `ClassAutoBuilderBase<TBase>` views previously exposed only the generated setters and `buildPartial()`, so shared methods could not fork, validate, or build on `this`. The views now also expose every brand-free member whose type is covariant in the base type: `buildUnsafe()`, `buildWithoutCompileTimeValidation()`, `clone()`, and `addValidator()`. The compile-gated build variants still require the derived builder's brand and stay derived-only.

To make this sound, `clone` and `addValidator` on the public `CommonAutoBuilderApi` are now declared in the same generic-`Self` shape the generated setters use (`clone<Self>(this: Self): Self` instead of `clone(): this`). Call sites behave identically — `Self` infers to the receiver.

Also documented and locked with tests: a shared method that sets a base-optional property a derived type strengthens to required counts toward the derived build gate **when annotated** with `BuilderWith<this, K>` / `ClassBuilderWith<this, K>` — the annotation re-resolves the brand against the concrete builder's target at the call site. With an inferred return type the brand stays the base-optional flavor and does not satisfy the strengthened gate.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
55 changes: 12 additions & 43 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,65 +11,34 @@ concurrency:
cancel-in-progress: true

jobs:
lint:
name: Lint, Type & Format Check
build-test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: "22"
node-version: 24
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Run linter and type check
- name: Linting & Type-aware lint
run: npm run lint

- name: Check formatting
run: npm run format:check

build:
name: Build Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Type check
run: npm run compile

- name: Build packages
run: npm run build

test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- name: Check formatting
run: npm run format:check

- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test

- name: Build packages
run: npm run build

- name: Run tests
run: npm run test
- name: Validate package exports
run: npm run check-exports
16 changes: 9 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,25 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
pull-requests: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
node-version: 24
cache: "npm"
registry-url: "https://registry.npmjs.org"

- name: Upgrade npm
run: npm install -g npm@latest
- name: Show Node and npm versions
run: |
node -v
npm -v

- name: Install dependencies
run: npm ci
Expand All @@ -42,8 +44,8 @@ jobs:
id: changesets
uses: changesets/action@v1
with:
version: npm run changeset:prepare-release
publish: npm run changeset:publish
version: npm run version-packages:prepare-release
publish: npm run release
commit: "chore: version packages"
title: "chore: version packages"
env:
Expand Down
Loading