Repository: logaretm/vee-validate
Pull request #5014 (2025-03-25 19:20:18)

fix: valibot intersections
🔎 __Overview__

I use the `required` field on `useField#meta` to automatically show an "Optional" text on an input. I found that having intersections in Valibot breaks this. For example, I have a PasswordSchema for reusability:
```ts
export const PasswordSchema = v.pipe(
  v.object({
    password: v.pipe(v.string(), v.minLength(8)),
    confirmPassword: v.pipe(v.string(), v.minLength(8)),
  }),
  v.forward(
    v.partialCheck(
      [['password'], ['confirmPassword']],
      (input) => input.password === input.confirmPassword,
      'The two passwords do not match.',
    ),
    ['confirmPassword'],
  ),
)
```
Which I then use in a form:
```ts
const schema = v.object({
  user: v.intersect([
    PasswordSchema,
    v.object({
      email: v.pipe(v.string(), v.email()),
    }),
  ]),
})
```
This resulted in `required` always being `true`.

I've implemented a fix for this along with two test cases.


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 604ca280db36947dd5e9709ce6984f1c03b19444

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/jack-allocate/vee-validate/new/fix/valibot-intersections?filename=.changeset/famous-wombats-tan.md&value=---%0A%22%40vee-validate%2Fvalibot%22%3A%20patch%0A---%0A%0Afix%3A%20valibot%20intersections%0A)



Patch:
diff --git a/.changeset/famous-wombats-tan.md b/.changeset/famous-wombats-tan.md
new file mode 100644
index 00000000..cd69cedf
--- /dev/null
+++ b/.changeset/famous-wombats-tan.md
@@ -0,0 +1,5 @@
+---
+"@vee-validate/valibot": patch
+---
+
+fix: valibot intersections
diff --git a/docs/src/components/UiLibraries.vue b/docs/src/components/UiLibraries.vue
index 8d8c12a9..0864c9e6 100644
--- a/docs/src/components/UiLibraries.vue
+++ b/docs/src/components/UiLibraries.vue
@@ -14,8 +14,8 @@ const libraries = [
   {
     name: 'Shadcn Vue',
     link: 'https://www.shadcn-vue.com/docs/components/form',
-    logoLight: '/img/shadcn.svg',
-    logoDark: '/img/shadcn.svg',
+    logoLight: '/v4/img/shadcn.svg',
+    logoDark: '/v4/img/shadcn.svg',
   },
   {
     name: 'Vuetify',
diff --git a/packages/valibot/src/index.ts b/packages/valibot/src/index.ts
index 6af1a377..9abf3565 100644
--- a/packages/valibot/src/index.ts
+++ b/packages/valibot/src/index.ts
@@ -25,6 +25,8 @@ import {
   LooseObjectSchema,
   getDotPath,
   Config,
+  IntersectSchema,
+  IntersectIssue,
 } from 'valibot';
 import { isIndex, isObject, merge, normalizeFormPath } from '../../shared';
 
@@ -134,6 +136,10 @@ function getSchemaForPath(
   path: string,
   schema: BaseSchema<unknown, unknown, BaseIssue<unknown>> | BaseSchemaAsync<unknown, unknown, BaseIssue<unknown>>,
 ): BaseSchema<unknown, unknown, BaseIssue<unknown>> | null {
+  if (isIntersectSchema(schema)) {
+    return schema.options.map(o => getSchemaForPath(path, o)).find(Boolean) ?? null;
+  }
+
   if (!isObjectSchema(schema)) {
     return null;
   }
@@ -151,6 +157,10 @@ function getSchemaForPath(
       return currentSchema;
     }
 
+    if (isIntersectSchema(currentSchema)) {
+      currentSchema = currentSchema.options.find(o => isObjectSchema(o) && o.entries[p]) ?? currentSchema;
+    }
+
     if (isObjectSchema(currentSchema)) {
       currentSchema = currentSchema.entries[p] || null;
       continue;
@@ -189,3 +199,12 @@ function isObjectSchema(
   | StrictObjectSchema<ObjectEntries, ErrorMessage<StrictObjectIssue> | undefined> {
   return isObject(schema) && 'entries' in schema;
 }
+
+function isIntersectSchema(
+  schema: BaseSchema<unknown, unknown, BaseIssue<unknown>> | BaseSchemaAsync<unknown, unknown, BaseIssue<unknown>>,
+): schema is IntersectSchema<
+  BaseSchema<unknown, unknown, BaseIssue<unknown>>[],
+  ErrorMessage<IntersectIssue> | undefined
+> {
+  return schema.type === 'intersect';
+}


Test patch:
diff --git a/packages/valibot/tests/valibot.spec.ts b/packages/valibot/tests/valibot.spec.ts
index 3fd8a6cb..0c474feb 100644
--- a/packages/valibot/tests/valibot.spec.ts
+++ b/packages/valibot/tests/valibot.spec.ts
@@ -568,6 +568,94 @@ test('reports required state for field-level schemas without a form context', as
   );
 });
 
+test('reports required state for schema intersections', async () => {
+  const metaSpy = vi.fn();
+  mountWithHoc({
+    setup() {
+      const schema = toTypedSchema(
+        v.intersect([
+          v.object({
+            req: v.string(),
+          }),
+          v.object({
+            nreq: v.optional(v.string()),
+          }),
+        ]),
+      );
+
+      useForm({
+        validationSchema: schema,
+      });
+
+      const { meta: req } = useField('req');
+      const { meta: nreq } = useField('nreq');
+
+      metaSpy({
+        req: req.required,
+        nreq: nreq.required,
+      });
+
+      return {
+        schema,
+      };
+    },
+    template: `<div></div>`,
+  });
+
+  await flushPromises();
+  await expect(metaSpy).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      req: true,
+      nreq: false,
+    }),
+  );
+});
+
+test('reports required state for schema intersections with nested fields', async () => {
+  const metaSpy = vi.fn();
+  mountWithHoc({
+    setup() {
+      const schema = toTypedSchema(
+        v.object({
+          fields: v.intersect([
+            v.object({
+              req: v.string(),
+            }),
+            v.object({
+              nreq: v.optional(v.string()),
+            }),
+          ]),
+        }),
+      );
+
+      useForm({
+        validationSchema: schema,
+      });
+
+      const { meta: req } = useField('fields.req');
+      const { meta: nreq } = useField('fields.nreq');
+
+      metaSpy({
+        req: req.required,
+        nreq: nreq.required,
+      });
+
+      return {
+        schema,
+      };
+    },
+    template: `<div></div>`,
+  });
+
+  await flushPromises();
+  await expect(metaSpy).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      req: true,
+      nreq: false,
+    }),
+  );
+});
+
 test('allows passing valibot config', async () => {
   let errors!: Ref<string[]>;
   const wrapper = mountWithHoc({