feat!: Rule<V> typed fields, issue constraints, tsgo build, stripInternal - #44
Merged
Conversation
Replace the per-file `bun build --production` + `tsc --emitDeclarationOnly` + extension-fixup pipeline with a single `tsgo -p tsconfig.build.json`, matching the sibling @zipbul/* packages (e.g. ashward). typecheck and typecheck:bench move to tsgo as well. - add @typescript/native-preview (exact pin, same as ashward) - tsconfig: add `types: ["bun"]` (tsgo does not auto-include bun ambient types) - tsconfig.build: `noEmitOnError` so a broken emit fails loud - delete scripts/build.sh and scripts/add-js-extensions.ts - drop stale `--production` enum-inlining comments and the knip `tsc` binary ignore Typecheck drops from ~1.75s to ~0.17s. Emitted .d.ts is byte-identical to tsc except cosmetic quote style. JS now ships extensionless relative imports (as the sibling packages do); baker is Bun-only (relies on Symbol.metadata), so Node-native ESM resolvability is not a supported consumption path, and bundlers resolve fine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enable `stripInternal` so @internal-tagged symbols (toBakerIssueSet, checkCallOptions, CircularPlaceholder, EmitContext.pathExpr) are removed from the emitted .d.ts while remaining exported from source for direct unit testing — the intended use of @internal. Two source fixes were required so stripping leaves no dangling references: - makeRule/makePlannedRule now return EmittableRule (not the internal InternalRule), so public rule constants (isString, min, …) surface as EmittableRule. @field already casts to InternalRule at the metadata boundary, so builders keep .plan. - toBakerIssueSet is no longer re-exported from the common barrel; the two runtime consumers import it directly from common/errors. - InternalRule keeps its @internal-free doc (it is shared metadata↔builder plumbing, never reachable through the exports map, so it needs no stripping). Verified: published .d.ts no longer contains the stripped helpers; a consumer typecheck (skipLibCheck:true) against the built package is clean and cannot import the stripped symbols; 2469 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The six meta-application helpers (applyValidation/applyExpose/applyTransform + decorateRuleDef/withGroups/wrapTransform) plus the inline flags/type/exclude/ message-context blocks all threaded the same (meta, propertyKey, options) trio — the exact param-threading pattern the seal-stage builders were refactored away from. Move them into a FieldMetaApplier class that holds the trio as fields, so the @field decorator now only validates the decorator context and parses args, then delegates application. Pure move; behavior and public API unchanged; field.ts stays 100% covered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`tsgo -p tsconfig.build.json` does not remove obsolete output, so a renamed or deleted source could leave a stale file that the `dist/**` files glob would publish. Prepend `rm -rf dist` (the old build.sh did this) so the build is self-cleaning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A validation issue now carries the failing rule's constraint parameters
(e.g. `{ min: 5 }` for `min(5)`), so consumers can build error messages without
re-deriving bounds. Additive: `BakerIssue.constraints?` is optional; type-check rules
and structural gates (empty constraints) attach no key.
This closes the emission layer at the point that made a naive attempt break: a rule
failure's message/context now resolve explicitly as "the rule's own, else the field-level
fallback", and constraints attach independently as rule-only — replacing the fragile
"empty-extras-string → reuse field-level ctx" control-flow trick. makeRuleEmitCtx reuses
the field-level `fieldExtras` already computed on the base EmitContext (no meta re-threading,
no double ref push) and only builds a rule-specific ctx when the rule actually contributes,
so rules that add nothing stay on the unchanged fast path.
- BakerIssue.constraints?: Record<string, unknown>
- constraints frozen at rule construction (defineRuleMetadata) — the object is a shared
reference exposed on the public issue, so it must not be mutable
- nested re-path (deserialize-codegen) propagates constraints
- message-function behavior unchanged: a rule-declared message fn still receives the rule's
constraints; an inherited field-level message fn still receives {}
Scope is deliberately Phase 1 (explicit resolution). Sink centralization was rejected by
adversarial review as unsafe (structural root guards always abort, even in collect mode);
`each`-path field-message parity was left as current behavior. Interaction matrix covered:
inheritance × constraints, string/function messages, discriminator context isolation,
nested/Set/Map/arrayOf, collect vs first-error, frozen constraints.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review (Codex + Grok, both verified independently) found the shallow `Object.freeze` insufficient: rules like `arrayContains` / `isEnum` (<8) / `arrayNotContains` validate against the SAME array reference they stamp into `constraints.values`, and that array was left mutable on the public issue. A consumer doing `issue.constraints.values.length = 0` corrupted the rule so a later `deserialize` of the same invalid input wrongly passed. Deep-clone-freeze the constraints at rule construction: plain objects and arrays are cloned and frozen (the rule owns its immutable copy, distinct from the validation closure), so mutating an issue cannot corrupt validation, and the caller's array passed to `isIn`/`isEnum`/`arrayContains` is never frozen out from under them. Non-plain values (Date/RegExp/instances) pass through by reference — rare in constraints and never a shared validation ref. Tests: mutate-then-revalidate stays failing with intact constraints; exposed constraints array is frozen; caller's original array is not frozen and is a distinct reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bare `deserialize` returns `T | BakerIssueSet | Promise<T | BakerIssueSet>`, so reading a property off its result does not typecheck — after `isBakerIssueSet` narrowing, TypeScript still cannot exclude the `Promise` arm (verified: `result.name` → TS2339). The Quick Start and Error Handling snippets read `.name`/`.errors` directly, so they never compiled as written. Switch both to `deserializeSync` (returns `T | BakerIssueSet`, no Promise arm) and add a short note steering readers to `deserializeSync`/`deserializeAsync` for a directly-usable type, with the bare union documented as the honest sync-or-async shape. Also document the new `issue.constraints` field in the BakerIssue shape and the error-handling example. Verified: the corrected snippets typecheck under strict + exactOptionalPropertyTypes; the old `deserialize` form reproduces TS2339. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`@Field` now rejects a rule applied to a field of the wrong type at COMPILE time: `@Field(isString) age!: number` and `@Field(isString, min(5)) x!: string` no longer typecheck. This uses the value type TC39 stage-3 field decorators hand the decorator, which baker previously discarded. Runtime behaviour is unchanged (type-only): all 2484 tests pass and codegen snapshots are untouched. Design (adversarially reviewed by Codex + Grok, then proven in-repo against a 4-category type-test matrix before implementation): - EmittableRule<V = unknown> carries a covariant phantom `__v?: V`; InternalRule<V>, makeRule<V>/makePlannedRule<V>/makeStringRule (<string>), createRule<V = unknown> thread it. - @field encoding: `Field<V, E = never>(...args: (EmittableRule<V> | ArrayOfMarker<E>)[])`. A homogeneous EmittableRule<V> rejects mixed rule domains at the argument; the marker's E is derived only from args (NoInfer on the return blocks contextual E inference); the field is constrained to `FieldValue<V, E>` = V (or V & container-of-E when a marker is present), widened by `| null | undefined` so optional/nullable fields compile. - Domains: string rules → string; number → number; isDate/minDate/maxDate → Date; array/ collection rules → array|Set|Map; isLatitude/isLongitude → string|number; equals/isIn/ notEquals/isNotIn → WidenLiteral (keeps the check, unlike a blanket escape); oneOf → union of its branches; arrayEvery → array of its element domain; isEnum → string|number; isInstance → InstanceType; isEmpty/isNotEmpty → `never` (universal: composes with any sibling without weakening it, and FieldValue maps an all-never domain to "any field"). - No `any`/`unknown` escape hatches (oxlint no-explicit-any is clean); the untyped/dynamic path stays available via `@Field({ rules: [...] })`. BREAKING: type-only — code that was already type-mismatched no longer compiles. Correctly typed userland is unaffected. semver-major. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a "Type-checked fields" section showing that `@Field(isString) age!: number` is a
compile error, plus the optional/nullable, equals/isIn, arrayOf, isEmpty, and dynamic
`{ rules }` escape-hatch behaviours. Note the type-safety parity with Zod in the FAQ.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- delete the unused `ArgElements` type (left over from an abandoned Field-encoding candidate; the shipped encoding uses `FieldValue<V, NoInfer<E>>`) - make the type-test a self-contained typecheck-only artifact: export its classes (satisfies noUnusedLocals) and drop the unused Baker/seal runtime scaffold - register test/type-tests/ as a knip entry so the typecheck-only file is not flagged knip clean, typecheck clean, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
DX overhaul for
@zipbul/baker(semver-major). Three consumer-facing changes plus internal build/infra work, each adversarially reviewed (Codex + Grok) and RED-first tested.Consumer-facing
@Fieldis compile-time type-checked (breaking). A rule on a wrong-typed field no longer typechecks —@Field(isString) age!: number,@Field(isString, min(5)) code!: stringare errors. All 114 rules carry their value domain; the field's own: Tis the source of truth. Noany/unknownescape hatches (oxlint-clean); dynamic lists stay available via@Field({ rules }). Verified consumer-side against the built dist.BakerIssue.constraints— the failing rule's parameters (e.g.{ min: 5 }), deep-frozen. Additive..d.ts(stripInternal).Internal / infra
tsgo(matches sibling@zipbul/*);distclean step.FieldMetaApplierextracted from@Field;ExposeValidatorkept as functions (coverage-gate-driven).deserializeSync; new "Type-checked fields" section.Verification
test/type-tests/rule-domain-types.tsand a consumer-dist probe (downstream getsType 'number' is not assignable to type 'string').🤖 Generated with Claude Code