Skip to content

feat(validators): respect unknownKeys strip mode in validate()#913

Open
robelest wants to merge 2 commits intoget-convex:mainfrom
robelest:feat/unknownKeys-strip-support
Open

feat(validators): respect unknownKeys strip mode in validate()#913
robelest wants to merge 2 commits intoget-convex:mainfrom
robelest:feat/unknownKeys-strip-support

Conversation

@robelest
Copy link
Copy Markdown

@robelest robelest commented Feb 11, 2026

Summary

  • validate() now respects unknownKeys: "strip" on v.object() validators.
  • parse() union handling now follows strict-first semantics for consistency across Convex repos and with Zod-style union ordering expectations.

What changed

packages/convex-helpers/validators.ts

  • validate() object unknown-field checks now skip rejection when (validator as any).unknownKeys === "strip".
  • stripUnknownFields() union selection now uses two passes:
    • First matching non-strip member with allowUnknownFields: false
    • Otherwise first matching strip object member with allowUnknownFields: true
  • Removed permissive fallback for non-strip members in parse() union stripping.

packages/convex-helpers/validators.test.ts

  • Added coverage for strict-vs-strip union precedence.
  • Added coverage for strip-member declaration-order behavior.
  • Added regression asserting strict-only unions reject extra fields during parse().

packages/convex-helpers/server/validators.test.ts

  • Updated union parsing tests to explicitly use strip-mode members where unknown-field stripping is expected.

Notes

  • The convex npm package (^1.31.0) does not yet emit unknownKeys in serialized validator JSON, so tests use monkey-patch helpers.
  • Combinators like partial(), addFieldsToValidator(), and vRequired() currently lose unknownKeys when reconstructing via v.object(fields); out of scope here.

Related


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Summary by CodeRabbit

  • New Features

    • Per-validator "strip unknown fields" behavior updated so parsing can remove unexpected fields while strict validation still rejects them; union handling now respects strip vs strict member semantics and member ordering.
  • Tests

    • Expanded coverage for strip-mode parsing: objects, unions, nested structures, arrays, matching order effects, strict vs strip interactions, and related error cases.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 11, 2026

📝 Walkthrough

Walkthrough

Adds and tests "strip" mode for object validators (via unknownKeys: "strip"): unknown fields are permitted for initial matching and can be removed during parsing; union selection and stripping logic updated to try strict matches first, then permissive strip-mode matches.

Changes

Cohort / File(s) Summary
Core validator logic
packages/convex-helpers/validators.ts
Introduce runtime detection for strip-mode object validators (isStripObjectValidator); add firstMatchingUnionMember to choose union branches based on includeStripObjects and allowUnknownFields; modify stripUnknownFields and parsing/validation flows to prefer strict matching then permissive (strip) matching.
Unit tests — strip-mode object validators
packages/convex-helpers/validators.test.ts
Add test helper to mark validators as unknownKeys: "strip" and extensive tests covering: unknown-field allowance/stripping, behavior with { throw: true }, type and required-field rejections, strict-mode rejection, parse stripping, and union behaviors (strip vs strict members, declaration-order effects, member reorder preservation).
Server tests — parse/union integration
packages/convex-helpers/server/validators.test.ts
Add tests asserting parse strips unknown fields for strip-mode unions and nested structures; verify union matching prefers the first compatible member in presence of unknown fields and that nested/array parsing respects strip-mode members.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I twitch my whiskers at extra keys,
I hop through unions with nimble ease.
I strip the crumbs, keep what’s true,
Tiny paws tidy, yes — that’s my cue.
Hooray for clean objects, freshly new!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(validators): respect unknownKeys strip mode in validate()' directly and specifically describes the main change: adding support for the unknownKeys strip mode in the validate() function.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Feb 13, 2026

Open in StackBlitz

npm i https://pkg.pr.new/get-convex/convex-helpers@913

commit: 5d3dc13

@ianmacartney
Copy link
Copy Markdown
Member

What happens if I pass this object:

{
  "a": 1,
  "b": 2,
  "c": 3,
}

to this validator:

v.union(
  v.object({
    a: v.number(),    
  }, { unknownFields: "strip" }),
  v.object({
    b: v.number(),
    c: v.optional(v.number()),
  }, { unknownFields: "strip" }),
  v.object({
    a: v.union(v.string(), v.number()),
    b: v.optional(v.number()),
    c: v.any(),
  }, { unknownFields: "strip" }),
)

? As a user I might expect to get { a: 1, b: 2, c: 3 } since it was possible, even though the first two technically matched.
Unfortunately we might need to rely on some heuristics here:

  1. What captures the most fields / strips the fewest number of fields
  2. Biasing towards "strict" objects.
  3. What is the most specific type

What have you thought about so far @robelest ?

@ianmacartney
Copy link
Copy Markdown
Member

FYI you need to put the PR template CLA disclaimer in the PR description to pass that test

@robelest
Copy link
Copy Markdown
Author

robelest commented Feb 13, 2026

@ianmacartney what do you think of something like this for the 3 prs

given value V and union members [M1, M2, ... Mn]:

best_strict = None        # first strict member that validates
best_strip  = None        # strip member with highest recognized-key count
best_score  = -1

for each member M:
    if M does not validate V: skip

    if M is strict:
        → return immediately (no stripping needed — it's a perfect match)

    if M is strip:
        score = count of V's keys that exist in M's fields
        if score > best_score:
            best_strip = M
            best_score = score

strip using best_strip (or no-op if none matched)

@ianmacartney
Copy link
Copy Markdown
Member

High level seems reasonable - though not sure how much time /overhead will be spent here.
Can you check what Zod does in these cases? Might be worth having consistent behavior with ours - especially since zod validators are sometimes used alongside.
The simplest approach would be "the first strict one that matches, otherwise the first stripped one that matches" - and then it's on the developer to order their validators

@robelest robelest force-pushed the feat/unknownKeys-strip-support branch from 281167b to 5d3dc13 Compare February 17, 2026 23:10
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
packages/convex-helpers/validators.test.ts (1)

56-60: Monkey-patching unknownKeys is acceptable as a temporary workaround.

The comment in the PR notes mentions this will be removed once the SDK natively serializes unknownKeys. Consider adding a // TODO: remove once convex SDK exposes unknownKeys comment to make the temporary nature explicit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/convex-helpers/validators.test.ts` around lines 56 - 60, Add an
inline TODO comment clarifying that the monkey-patch is temporary: update the
helper function withStripUnknownKeys (which sets validator.unknownKeys =
"strip") to include a comment like "// TODO: remove once convex SDK exposes
unknownKeys" so future maintainers know this workaround should be removed when
the SDK natively supports unknownKeys.
packages/convex-helpers/validators.ts (1)

856-862: Type predicate narrows to VObject but doesn't encode the strip constraint.

The return type validator is VObject<any, any, any> is accurate for the runtime check but doesn't distinguish strip-mode objects at the type level. This is fine for internal use today, but consider adding a brief doc comment noting the unknownKeys property isn't yet in the public VObject type.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/convex-helpers/validators.ts` around lines 856 - 862,
isStripObjectValidator currently narrows the runtime validator to VObject but
also checks the internal unknownKeys === "strip" flag which isn't represented in
the public VObject type; add a brief doc comment above the
isStripObjectValidator function explaining that this predicate ensures both kind
=== "object" and unknownKeys === "strip" at runtime and that the unknownKeys
property is an internal implementation detail not exposed on the public VObject
type, so callers should not rely on the type system to reflect the strip-mode
constraint.
packages/convex-helpers/server/validators.test.ts (1)

345-348: Duplicate withStripUnknownKeys helper across test files.

This helper is identical to the one in validators.test.ts (line 57). Consider extracting it to a shared test utility (e.g., a test-utils.ts file) to avoid duplication — especially since both will need to be removed when the SDK adds native unknownKeys support.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/convex-helpers/server/validators.test.ts` around lines 345 - 348,
Duplicate helper withStripUnknownKeys (which sets (validator as any).unknownKeys
= "strip" for a v.object validator) should be extracted into a shared test
utility; create a test-utils.ts that exports function
withStripUnknownKeys(validator: ReturnType<typeof v.object>) and move the
implementation there, then replace the local definitions in validators.test.ts
and the other test file with an import from that shared module and remove the
duplicate definitions; ensure the exported signature matches usages and update
imports in tests to reference the new withStripUnknownKeys helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/convex-helpers/validators.ts`:
- Around line 914-931: The current two-pass union logic uses
firstMatchingUnionMember(validator.members, ..., {allowUnknownFields: false,
includeStripObjects: false}) which causes parse() to throw for unions of plain
v.object members with extra fields; change the strict-pass call so it uses
allowUnknownFields: true (i.e., call firstMatchingUnionMember(...,
{allowUnknownFields: true, includeStripObjects: false})) so non-strip object
members still match when inputs contain extra fields, then keep the second
strip-pass (includeStripObjects: true) to handle stripable members and call
stripUnknownFields on the matched member as before.

---

Nitpick comments:
In `@packages/convex-helpers/server/validators.test.ts`:
- Around line 345-348: Duplicate helper withStripUnknownKeys (which sets
(validator as any).unknownKeys = "strip" for a v.object validator) should be
extracted into a shared test utility; create a test-utils.ts that exports
function withStripUnknownKeys(validator: ReturnType<typeof v.object>) and move
the implementation there, then replace the local definitions in
validators.test.ts and the other test file with an import from that shared
module and remove the duplicate definitions; ensure the exported signature
matches usages and update imports in tests to reference the new
withStripUnknownKeys helper.

In `@packages/convex-helpers/validators.test.ts`:
- Around line 56-60: Add an inline TODO comment clarifying that the monkey-patch
is temporary: update the helper function withStripUnknownKeys (which sets
validator.unknownKeys = "strip") to include a comment like "// TODO: remove once
convex SDK exposes unknownKeys" so future maintainers know this workaround
should be removed when the SDK natively supports unknownKeys.

In `@packages/convex-helpers/validators.ts`:
- Around line 856-862: isStripObjectValidator currently narrows the runtime
validator to VObject but also checks the internal unknownKeys === "strip" flag
which isn't represented in the public VObject type; add a brief doc comment
above the isStripObjectValidator function explaining that this predicate ensures
both kind === "object" and unknownKeys === "strip" at runtime and that the
unknownKeys property is an internal implementation detail not exposed on the
public VObject type, so callers should not rely on the type system to reflect
the strip-mode constraint.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
packages/convex-helpers/validators.ts (3)

758-761: Add an inline comment explaining why strip objects bypass the extra-fields check unconditionally.

This two-part condition encodes an important invariant: strip-mode objects ALWAYS accept unknown fields during validate(), even when allowUnknownFields: false. This is precisely why firstMatchingUnionMember must explicitly skip strip objects in the first pass — relying on validate() to reject them would not work. Without a comment, a future reader may be tempted to simplify the condition or invert the unknownKeys check, breaking the strict-pass logic.

📝 Suggested clarification comment
+        // Note: strip objects intentionally bypass the unknown-fields check
+        // regardless of allowUnknownFields. This invariant lets
+        // firstMatchingUnionMember explicitly skip them in the strict first
+        // pass rather than relying on validate() to reject them.
         if (
           !opts?.allowUnknownFields &&
           (validator as any).unknownKeys !== "strip"
         ) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/convex-helpers/validators.ts` around lines 758 - 761, Add an inline
comment next to the condition that checks opts?.allowUnknownFields and
(validator as any).unknownKeys !== "strip" explaining that strip-mode validators
intentionally accept unknown fields during validate() even when
allowUnknownFields is false, and that this behavior is why
firstMatchingUnionMember must skip strip objects in its first pass rather than
relying on validate() to reject them; mention the involved symbols: unknownKeys,
allowUnknownFields, validate(), and firstMatchingUnionMember so future readers
won't invert or simplify the check.

920-941: Missing test: mixed-mode union (strip + non-strip members) where member order determines the stripping result.

In the second pass, the first member in declaration order wins. For a union like v.union(stripMember({a}), strictMember({a, b})) with value {a:"x", b:1, extra:"field"}:

  1. First pass: stripMember is skipped; strictMember fails (extra is an unknown field) → undefined.
  2. Second pass: stripMember appears first, matches with allowUnknownFields: true → result is {a: "x"} (b is silently dropped).

Reversing the order (strictMember first) preserves b. The tests at lines 759–770 document this for strip-only unions, but the mixed case is absent. A developer mixing strip and non-strip members in the same union may be surprised that a leading strip member causes the subsequent non-strip member — which would capture more fields — to be bypassed.

Would you like me to draft a test that documents this ordering-sensitive behavior? For example:

test("mixed union: declaration order determines strip result when extra fields present", () => {
  // strip member listed first → wins in second pass, drops 'b'
  const stripFirst = v.union(
    withStripUnknownKeys(v.object({ a: v.string() })),
    v.object({ a: v.string(), b: v.number() }),
  );
  expect(parse(stripFirst, { a: "x", b: 1, extra: "!" })).toEqual({ a: "x" });

  // non-strip member listed first → wins in second pass, keeps 'b'
  const strictFirst = v.union(
    v.object({ a: v.string(), b: v.number() }),
    withStripUnknownKeys(v.object({ a: v.string() })),
  );
  expect(parse(strictFirst, { a: "x", b: 1, extra: "!" })).toEqual({ a: "x", b: 1 });
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/convex-helpers/validators.ts` around lines 920 - 941, Add a unit
test that documents the ordering-sensitive behavior of union members when mixing
strip and strict object validators: construct two unions (using v.union with
withStripUnknownKeys(v.object(...)) and v.object(...)), one with the
strip-member first and one with the strict-member first, then parse a value
containing both expected fields and an extra field and assert the first
preserves only the stripped fields while the second preserves all strict-member
fields; reference the union behavior exercised by firstMatchingUnionMember and
stripUnknownFields so the test explicitly demonstrates how declaration order
affects which member wins during the second (permissive) pass.

870-886: includeStripObjects semantics are inverted from what the name implies — consider renaming.

When includeStripObjects: true, the skip condition isStripObjectValidator(member) && !opts.includeStripObjects evaluates to false for every member (both strip and non-strip), so all members are iterated. The name implies "only include strip objects," but the flag really controls whether strip objects are excluded. Callers reading the two call sites in stripUnknownFields have to trace the logic to understand what includeStripObjects: true does.

♻️ Suggested rename
 function firstMatchingUnionMember(
   members: Validator<any, any, any>[],
   value: unknown,
-  opts: { allowUnknownFields: boolean; includeStripObjects: boolean },
+  opts: { allowUnknownFields: boolean; excludeStripObjects: boolean },
 ): Validator<any, any, any> | undefined {
   for (const member of members) {
-    if (isStripObjectValidator(member) && !opts.includeStripObjects) {
+    if (isStripObjectValidator(member) && opts.excludeStripObjects) {
       continue;
     }

And update call sites in stripUnknownFields:

-  const strictMember = firstMatchingUnionMember(validator.members, value, {
-    allowUnknownFields: false,
-    includeStripObjects: false,
-  });
+  const strictMember = firstMatchingUnionMember(validator.members, value, {
+    allowUnknownFields: false,
+    excludeStripObjects: true,
+  });
   ...
-  const permissiveMember = firstMatchingUnionMember(validator.members, value, {
-    allowUnknownFields: true,
-    includeStripObjects: true,
-  });
+  const permissiveMember = firstMatchingUnionMember(validator.members, value, {
+    allowUnknownFields: true,
+    excludeStripObjects: false,
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/convex-helpers/validators.ts` around lines 870 - 886, The flag
includeStripObjects in firstMatchingUnionMember is named/used incorrectly
(semantics are inverted); rename it to excludeStripObjects and invert the
condition: change the parameter to opts: { allowUnknownFields: boolean;
excludeStripObjects: boolean }, and update the loop guard to if
(isStripObjectValidator(member) && opts.excludeStripObjects) continue; then
update the two call sites in stripUnknownFields to pass the inverted boolean
(i.e., replace includeStripObjects: X with excludeStripObjects: !X) so the
meaning matches the name.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/convex-helpers/validators.ts`:
- Around line 920-941: Remove the duplicate review comment and keep the union
branch logic as-is; add a short inline comment above the
firstMatchingUnionMember/second pass in the case "union" block clarifying that
the second pass (includeStripObjects: true) causes allowUnknownFields to be
effectively true for all members (so strict-only unions are handled), and run
the existing tests (including "parse strips unknown fields from unions") to
confirm no regression; references: case "union", firstMatchingUnionMember,
stripUnknownFields.

---

Nitpick comments:
In `@packages/convex-helpers/validators.ts`:
- Around line 758-761: Add an inline comment next to the condition that checks
opts?.allowUnknownFields and (validator as any).unknownKeys !== "strip"
explaining that strip-mode validators intentionally accept unknown fields during
validate() even when allowUnknownFields is false, and that this behavior is why
firstMatchingUnionMember must skip strip objects in its first pass rather than
relying on validate() to reject them; mention the involved symbols: unknownKeys,
allowUnknownFields, validate(), and firstMatchingUnionMember so future readers
won't invert or simplify the check.
- Around line 920-941: Add a unit test that documents the ordering-sensitive
behavior of union members when mixing strip and strict object validators:
construct two unions (using v.union with withStripUnknownKeys(v.object(...)) and
v.object(...)), one with the strip-member first and one with the strict-member
first, then parse a value containing both expected fields and an extra field and
assert the first preserves only the stripped fields while the second preserves
all strict-member fields; reference the union behavior exercised by
firstMatchingUnionMember and stripUnknownFields so the test explicitly
demonstrates how declaration order affects which member wins during the second
(permissive) pass.
- Around line 870-886: The flag includeStripObjects in firstMatchingUnionMember
is named/used incorrectly (semantics are inverted); rename it to
excludeStripObjects and invert the condition: change the parameter to opts: {
allowUnknownFields: boolean; excludeStripObjects: boolean }, and update the loop
guard to if (isStripObjectValidator(member) && opts.excludeStripObjects)
continue; then update the two call sites in stripUnknownFields to pass the
inverted boolean (i.e., replace includeStripObjects: X with excludeStripObjects:
!X) so the meaning matches the name.

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.

2 participants