Skip to content

fix(v4): apply key schema transforms in z.record()#5891

Merged
colinhacks merged 2 commits into
mainfrom
fix/record-key-transform
Apr 29, 2026
Merged

fix(v4): apply key schema transforms in z.record()#5891
colinhacks merged 2 commits into
mainfrom
fix/record-key-transform

Conversation

@colinhacks
Copy link
Copy Markdown
Owner

Summary

z.record() with a finite-key keyType (literal, enum, union of literals, etc.) skipped the keyType entirely on its fast path and used the original input key as the output property name. Any .transform() chained onto the key schema was therefore dropped at runtime, contradicting the inferred output type.

z.partialRecord() already did the right thing — it works by clearing the keyType's values to force the slow path, where the keyType is parsed per input key.

z.record(z.literal("a").transform(() => "b" as const), z.string())
  .parse({ a: "John" });
// before: { a: "John" }   ← key not transformed, type lied
// after:  { b: "John" }

Approach

Run the keyType once per known input value to derive the output property name. The fast path's job — enforcing required-key semantics for finite-key records (Record<K, V> vs Partial<Record<K, V>>) and detecting unrecognized keys — is preserved. Lookup into the input object still uses the input-side key.

This is the same key-resolution work the slow path already does and that partialRecord relies on; the fast path was the lone code path skipping it.

Behavior

  • z.record(z.literal("a").transform(() => "b"), v).parse({ a: 1 }){ b: 1 } (was { a: 1 }).
  • z.record(z.literal(["a","b"]), v).parse({ a: 1 }) — still fails on missing required key b.
  • z.record(z.literal(["a","b"]), v).parse({ a: 1, c: 2 }) — still flags c as unrecognized.
  • z.partialRecord(...) — unchanged.
  • Discriminated unions over codec/transform discriminators — unchanged (this fix doesn't touch values/propValues propagation).

Fixes #5296. Supersedes #5867 (which added the same intent but without the underlying source change and bundled unrelated build-config edits).

Test plan

  • Added regression test covering z.literal(...).transform(...), multi-value literal+transform, enum+transform, required-key semantics, and partialRecord parity.
  • Existing record + discriminated-union test files pass.
  • Full vitest suite (3713 tests) passes locally.

z.record() with a finite-key keyType (literal, enum, etc.) skipped the
keyType entirely on its fast path and used the original input key as the
output property name. Any .transform() chained onto the key schema was
therefore dropped at runtime, contradicting the inferred output type.
z.partialRecord() already did the right thing because it clears the
keyType's `values` to force the slow path.

Run the keyType once per known input value to derive the output property
name, so transforms apply. Required-key semantics (the reason the fast
path exists) and unrecognized-key detection are preserved.

Fixes #5296.
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 28, 2026

Reviewed PR #5891 — approved with no issues. The fix correctly applies key schema transforms in the known-values branch of z.record(), making it consistent with the open-key-set path. All tests pass.

Task list (4/4 completed)
  • Read the PR diff and understand the changes
  • Read surrounding source code for context
  • Verify correctness and edge cases
  • Submit review

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 28, 2026

TL;DRz.record() with a finite-key schema (literal, enum, union of literals) was skipping the key schema's .transform() on its fast path, using the raw input key as the output property name. This fix runs the key schema per known value so transforms are applied and refinement failures surface as invalid_key errors — matching the behavior the slow path (and z.partialRecord()) already had.

Key changes

  • Apply key schema transforms on $ZodRecord fast path — runs def.keyType._zod.run() for each known input key to derive the output property name. Both sync and async value paths use the transformed key.
  • Surface key schema failures as invalid_key errors — when the key schema produces issues (e.g. a .refine() rejects a key), the fast path now emits a proper invalid_key issue instead of silently falling back to the original key.
  • Regression tests for key transforms and refinements — covers single-literal transform, multi-value literal transform, enum transform, required-key semantics, partialRecord parity, and key-level refinement failures with inline snapshot.

Summary | 2 files | 2 commits | base: mainfix/record-key-transform


Before: the fast path for finite-key records copied values into the output object using the original input key, silently discarding any .transform() on the key schema — the inferred output type said the key was transformed, but the runtime value disagreed. Key-level refinements were also silently swallowed.
After: each known key value is run through the key schema before being used as the output property name; transforms are applied, refinement failures produce invalid_key errors, and the runtime output matches the inferred type.

The fast path in $ZodRecord iterates over the key schema's values set to enforce required-key and unrecognized-key semantics. Previously it used those raw values directly as output property names. Now it calls def.keyType._zod.run() per key to obtain the transformed output name (e.g. "a""b"). When the key schema produces issues — such as a .refine() rejecting a value that's still in the literal's value set — the fast path pushes an invalid_key issue with the finalized sub-issues and skips that entry via continue. Async key schemas throw explicitly since object keys can't be resolved asynchronously.

Why does partialRecord already work?

z.partialRecord() clears the key schema's values set, which forces the record parser onto the slow path. The slow path already ran the key schema per input key — only the fast path was skipping it.

packages/zod/src/v4/core/schemas.ts · packages/zod/src/v4/classic/tests/record.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no issues found.

The known-values branch was the only path that skipped running the key schema, so output keys were never transformed. The else branch (open key set, used by partialRecord) already used keyResult.value — this fix makes the two branches consistent. Error paths and unrecognized-key detection correctly continue to use the original input-side key. Tests pass (runtime + typecheck).

Task list (4/4 completed)
  • Read the PR diff and understand the changes
  • Read surrounding source code for context
  • Verify correctness and edge cases
  • Submit review

Pullfrog  | View workflow run | Using Claude Opus𝕏

When the keyType has a known value set, run any .refine()/.transform()
errors through `invalid_key` rather than silently falling back to the
input key. Matches the behavior of the non-exhaustive path.
@colinhacks colinhacks merged commit 61d7bed into main Apr 29, 2026
6 checks passed
@colinhacks colinhacks deleted the fix/record-key-transform branch April 29, 2026 01:53
@colinhacks
Copy link
Copy Markdown
Owner Author

Landed in Zod 4.4

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.

Records do not transform keys, even though the Typescript types says it does.

1 participant