Skip to content

feat!: Rule<V> typed fields, issue constraints, tsgo build, stripInternal - #44

Merged
parkrevil merged 11 commits into
mainfrom
build/tsgo-and-strip-internal
Jul 12, 2026
Merged

feat!: Rule<V> typed fields, issue constraints, tsgo build, stripInternal#44
parkrevil merged 11 commits into
mainfrom
build/tsgo-and-strip-internal

Conversation

@parkrevil

Copy link
Copy Markdown
Contributor

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

  • @Field is compile-time type-checked (breaking). A rule on a wrong-typed field no longer typechecks — @Field(isString) age!: number, @Field(isString, min(5)) code!: string are errors. All 114 rules carry their value domain; the field's own : T is the source of truth. No any/unknown escape 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.
  • Internal declarations stripped from published .d.ts (stripInternal).

Internal / infra

  • Build + typecheck migrated to tsgo (matches sibling @zipbul/*); dist clean step.
  • FieldMetaApplier extracted from @Field; ExposeValidator kept as functions (coverage-gate-driven).
  • README: Quick Start/Error Handling use deserializeSync; new "Type-checked fields" section.

Verification

  • typecheck (tsgo) 0, lint 0/0, knip clean, 2484 tests pass, coverage gate green, test:memory pass, build OK.
  • Rule proven RED-first via test/type-tests/rule-domain-types.ts and a consumer-dist probe (downstream gets Type 'number' is not assignable to type 'string').
  • Runtime is unchanged (type-only): codegen snapshots untouched.

🤖 Generated with Claude Code

parkrevil and others added 11 commits July 11, 2026 23:39
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>
@parkrevil
parkrevil merged commit 11c1006 into main Jul 12, 2026
1 check passed
@parkrevil
parkrevil deleted the build/tsgo-and-strip-internal branch July 12, 2026 04:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant