fix: infer each generated schema's own concrete type - #6
Merged
Conversation
Every schema was declared as "export const xSchema: z.ZodType = ...", forcing the generic base type onto every const regardless of its own shape. z.infer<typeof xSchema> then collapsed to unknown for every single rule, not just the genuinely recursive ones -- the entire point of generating a schema with an attached inferred type was defeated. Only a rule that's actually part of a z.lazy() reference cycle (e.g. frame's own union includes federation-envelope-frame, whose inner field is .cbor frame -- a reference straight back to frame) needs the explicit annotation to satisfy TypeScript's circular-inference check; every other rule can infer its own concrete shape. mergeRules/emit* now collect which rule names each rule's own expression references via z.lazy(), and emitModule computes the actual reference graph across all rules to decide, per rule, whether it needs the annotation. Verified against wire-mesh's real spec: 0 of 70 rules are in a reference cycle, so the generated module now compiles with zero explicit annotations, and each generated type (e.g. Frame, HandshakeFrame) is confirmed concrete -- both accessing a real field and a deliberately nonexistent one via @ts-expect-error typecheck as expected, which would both silently pass under `any`/`unknown`.
Mearman
marked this pull request as ready for review
September 10, 2026 09:20
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
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.
Discovered while wiring wire-mesh's ts/packages/core to consume the generated schemas: every single generated type (Frame, HandshakeFrame, TokenClaims, all 70 of them against the real spec) was
unknown, not the schema's actual shape.Root cause: every schema constant was declared `export const xSchema: z.ZodType = z.lazy(() => ...)`, and the explicit `: z.ZodType` annotation forces the generic base type onto the const regardless of what the schema actually validates. `z.infer` then resolves to `unknown` for every rule, defeating the entire point of generating a schema with an attached inferred type.
The annotation exists to satisfy TypeScript's circular-inference restriction, which only actually applies to a rule genuinely part of a `z.lazy()` reference cycle (e.g. `frame`'s own union includes `federation-envelope-frame`, whose `inner` field is `.cbor frame` -- a reference straight back to `frame`). Every emit* function now collects which other rule names a rule's own expression references via `z.lazy()`; `emitModule` builds the full reference graph afterwards and only keeps the explicit annotation on rules that are actually cyclic.
Verified against wire-mesh's real spec: 0 of 70 rules turn out to be cyclic, so the generated module compiles with zero explicit annotations now, and `Frame`/`HandshakeFrame` are confirmed genuinely concrete (accessing a real field typechecks, accessing a made-up one via `@ts-expect-error` is correctly flagged -- both would silently pass under `any`/`unknown`).