From 04a6eb90c76fe0dcbcc3d144966ce3fd98d51c96 Mon Sep 17 00:00:00 2001 From: Jan-Paul Kleemans Date: Fri, 29 Aug 2025 09:04:25 +0200 Subject: [PATCH 1/4] Experiment with union types --- src/index.ts | 6 +++--- src/types.ts | 49 ++++++++++++++++++++++++++++++++++++++++----- tests/index.test.ts | 32 +++++++++++++++++------------ 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1a4f581..d193502 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' -import type { Issues } from './types' +import type { Object, Issues } from './types' import { reactive, watch, toValue, type MaybeRefOrGetter } from 'vue' -export function useValidation>(data: MaybeRefOrGetter, schema: StandardSchemaV1) { - const issues = reactive>({}) +export function useValidation(data: MaybeRefOrGetter, schema: StandardSchemaV1) { + const issues = reactive>({}) const clearIssues = () => Object.keys(issues).forEach((key) => delete issues[key]) diff --git a/src/types.ts b/src/types.ts index 0198dfc..0c9da80 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,46 @@ -type Combine = T extends unknown - ? T & Partial, never>> - : never +import { reactive } from "vue" +import z from "zod" +import { useValidation } from "." -export type Issues = { - [Key in keyof Combine]?: Combine[Key] extends object ? Issues[Key]> : string[] +/** + * Generates a union of all property keys from T. + * If T is a union, produces a union of keys from all variants. + * Example: `{ user, pass } | { code }` results in `'user' | 'pass' | 'code'`. + */ +type KeysOf = T extends unknown ? keyof T : never + +export type Object = Record + +type ArrayOrObject = unknown[] | Object + +export type Issues = { + [TKey in KeysOf]?: TData[TKey] extends ArrayOrObject + ? Issues // if property value is object or array: recurse + : string[] // else: array of messages } + +/// + +type Firts = { mode: 'login'; auth: { password: string } } | { mode: 'login'; auth: { code: string } } + +const loginSchema = z.object({ + mode: z.literal('login'), + auth: z.object({ + password: z.string().min(1, 'Password is required'), + }), +}) + +const signupSchema = z.object({ + bert: z.literal('signup'), + auth: z.object({ + code: z.string().length(6, 'Code is required'), + }), +}) + +const authSchema = z.discriminatedUnion('mode', [loginSchema, signupSchema]) + +const form = reactive({ mode: 'login', auth: { password: 'pass' } }) + +const { validate, issues } = useValidation(form, authSchema) + +console.log(issues.auth?.code) diff --git a/tests/index.test.ts b/tests/index.test.ts index c9bcbff..f326c0e 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -292,23 +292,29 @@ describe('useValidation', () => { }) it('should handle null and undefined values gracefully', async () => { - const schema = z.object({ - optional: z.string().optional(), - nullable: z.string().nullable(), - required: z.string(), + type AuthPayload = { mode: 'login'; auth: { password: string } } + + const loginSchema = z.object({ + mode: z.literal('login'), + auth: z.object({ + password: z.string().min(1, 'Password is required'), + }), }) - const data = reactive({ - optional: undefined, - nullable: null, - required: null, - } as any) + const signupSchema = z.object({ + mode: z.literal('signup'), + auth: z.object({ + code: z.string().length(6, 'Code is required'), + }), + }) - const { validate, issues } = useValidation(data, schema) - const isValid = await validate() + const authSchema = z.discriminatedUnion('mode', [loginSchema, signupSchema]) - expect(isValid).toBe(false) - expect(issues.required).toBeDefined() // Should have error for required field + const form = reactive({ mode: 'login', auth: { password: 'pass' } }) + + const { validate, issues } = useValidation(form, authSchema) + + console.log(issues.auth?.code) }) it('should handle very deep nesting', async () => { From 75211c1772a4f8683535cbe9a346e4708430ac2f Mon Sep 17 00:00:00 2001 From: Jan-Paul Kleemans Date: Fri, 29 Aug 2025 09:54:15 +0200 Subject: [PATCH 2/4] Cleanup --- src/index.ts | 6 +++--- src/types.ts | 46 +++++---------------------------------------- tests/index.test.ts | 28 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 44 deletions(-) diff --git a/src/index.ts b/src/index.ts index d193502..6359c6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' -import type { Object, Issues } from './types' +import type { AnyRecord, Issues } from './types' import { reactive, watch, toValue, type MaybeRefOrGetter } from 'vue' -export function useValidation(data: MaybeRefOrGetter, schema: StandardSchemaV1) { - const issues = reactive>({}) +export function useValidation(data: MaybeRefOrGetter, schema: StandardSchemaV1) { + const issues = reactive>({}) const clearIssues = () => Object.keys(issues).forEach((key) => delete issues[key]) diff --git a/src/types.ts b/src/types.ts index 0c9da80..d40ae63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,46 +1,10 @@ -import { reactive } from "vue" -import z from "zod" -import { useValidation } from "." - -/** - * Generates a union of all property keys from T. - * If T is a union, produces a union of keys from all variants. - * Example: `{ user, pass } | { code }` results in `'user' | 'pass' | 'code'`. - */ +/** Generates a union of all property keys from T, including keys from all union members when T is a union itself. */ type KeysOf = T extends unknown ? keyof T : never -export type Object = Record - -type ArrayOrObject = unknown[] | Object - -export type Issues = { - [TKey in KeysOf]?: TData[TKey] extends ArrayOrObject - ? Issues // if property value is object or array: recurse +export type Issues = { + [K in KeysOf]?: T[K] extends object + ? Issues // if property value is object (or array): recurse : string[] // else: array of messages } -/// - -type Firts = { mode: 'login'; auth: { password: string } } | { mode: 'login'; auth: { code: string } } - -const loginSchema = z.object({ - mode: z.literal('login'), - auth: z.object({ - password: z.string().min(1, 'Password is required'), - }), -}) - -const signupSchema = z.object({ - bert: z.literal('signup'), - auth: z.object({ - code: z.string().length(6, 'Code is required'), - }), -}) - -const authSchema = z.discriminatedUnion('mode', [loginSchema, signupSchema]) - -const form = reactive({ mode: 'login', auth: { password: 'pass' } }) - -const { validate, issues } = useValidation(form, authSchema) - -console.log(issues.auth?.code) +export type AnyRecord = Record diff --git a/tests/index.test.ts b/tests/index.test.ts index f326c0e..e489cba 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -398,6 +398,34 @@ describe('useValidation', () => { expect(issues.address?.street).toBeDefined() expect(issues.address?.city).toBeDefined() }) + + it('should accept union typed data', async () => { + type Auth = { mode: 'login'; auth: { password: string } } | { mode: 'signup'; auth: { code: string } } + + const loginSchema = z.object({ + mode: z.literal('login'), + auth: z.object({ + password: z.string().min(1, 'Password is required'), + }), + }) + + const signupSchema = z.object({ + mode: z.literal('signup'), + auth: z.object({ + code: z.string().length(6, 'Code is required'), + }), + }) + + const schema = z.discriminatedUnion('mode', [loginSchema, signupSchema]) + + const data = reactive({ mode: 'login', auth: { password: '' } }) + + const { validate, issues } = useValidation(data, schema) + await validate() + + expect(issues.auth?.password).toBeDefined() + expect(issues.auth?.code).not.toBeDefined() + }) }) describe('clearIssues function', () => { From ce24cd98bbb0fcc8478f24d752c974792349c341 Mon Sep 17 00:00:00 2001 From: Jan-Paul Kleemans Date: Fri, 29 Aug 2025 09:55:21 +0200 Subject: [PATCH 3/4] Revert test --- tests/index.test.ts | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/tests/index.test.ts b/tests/index.test.ts index e489cba..e2f3e7e 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -292,29 +292,23 @@ describe('useValidation', () => { }) it('should handle null and undefined values gracefully', async () => { - type AuthPayload = { mode: 'login'; auth: { password: string } } - - const loginSchema = z.object({ - mode: z.literal('login'), - auth: z.object({ - password: z.string().min(1, 'Password is required'), - }), - }) - - const signupSchema = z.object({ - mode: z.literal('signup'), - auth: z.object({ - code: z.string().length(6, 'Code is required'), - }), + const schema = z.object({ + optional: z.string().optional(), + nullable: z.string().nullable(), + required: z.string(), }) - const authSchema = z.discriminatedUnion('mode', [loginSchema, signupSchema]) - - const form = reactive({ mode: 'login', auth: { password: 'pass' } }) + const data = reactive({ + optional: undefined, + nullable: null, + required: null, + } as any) - const { validate, issues } = useValidation(form, authSchema) + const { validate, issues } = useValidation(data, schema) + const isValid = await validate() - console.log(issues.auth?.code) + expect(isValid).toBe(false) + expect(issues.required).toBeDefined() // Should have error for required field }) it('should handle very deep nesting', async () => { From 61f9d76236257a0fe139935f87c6035714e6abbe Mon Sep 17 00:00:00 2001 From: Jan-Paul Kleemans Date: Fri, 29 Aug 2025 10:30:59 +0200 Subject: [PATCH 4/4] Accept more complex union nesting --- src/types.ts | 11 ++++++++--- tests/index.test.ts | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/types.ts b/src/types.ts index d40ae63..f883bea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,15 @@ -/** Generates a union of all property keys from T, including keys from all union members when T is a union itself. */ +/** Generate a union of all property keys from T, including keys from all members when T is a union itself. */ type KeysOf = T extends unknown ? keyof T : never +/** Pick property type for a possibly missing key */ +type PropType = T extends { [P in K]?: unknown } ? T[K] : never + +/** Nested record of issue message arrays */ export type Issues = { - [K in KeysOf]?: T[K] extends object - ? Issues // if property value is object (or array): recurse + [K in KeysOf]?: PropType extends object + ? Issues> // if property type is object (or array): recurse : string[] // else: array of messages } +/** Any record with unknown value type */ export type AnyRecord = Record diff --git a/tests/index.test.ts b/tests/index.test.ts index e2f3e7e..3ae5ee8 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -393,13 +393,18 @@ describe('useValidation', () => { expect(issues.address?.city).toBeDefined() }) - it('should accept union typed data', async () => { - type Auth = { mode: 'login'; auth: { password: string } } | { mode: 'signup'; auth: { code: string } } + it('should accept complex union typed data', async () => { + type Auth = + | { mode: 'login'; auth: { password: string; email: { address: string } } } + | { mode: 'signup'; auth: { code: string; provider: { id: string } } } const loginSchema = z.object({ mode: z.literal('login'), auth: z.object({ password: z.string().min(1, 'Password is required'), + email: z.object({ + address: z.email('Invalid email address'), + }), }), }) @@ -407,18 +412,23 @@ describe('useValidation', () => { mode: z.literal('signup'), auth: z.object({ code: z.string().length(6, 'Code is required'), + provider: z.object({ + id: z.enum(['basic', 'oauth'], 'Invalid provider'), + }), }), }) const schema = z.discriminatedUnion('mode', [loginSchema, signupSchema]) - const data = reactive({ mode: 'login', auth: { password: '' } }) + const data = reactive({ mode: 'login', auth: { password: '', email: { address: '' } } }) const { validate, issues } = useValidation(data, schema) await validate() expect(issues.auth?.password).toBeDefined() + expect(issues.auth?.email?.address).toBeDefined() expect(issues.auth?.code).not.toBeDefined() + expect(issues.auth?.provider?.id).not.toBeDefined() }) })