From d5480fee3dce0d8999be22de58f9e463308f2619 Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Thu, 9 Jul 2026 08:42:12 +0200 Subject: [PATCH 1/4] feat: added dynamic discriminator keys --- .../get-condition-dependencies.test.ts | 47 +++++++++- .../conditions/get-condition-dependencies.ts | 19 +++- .../discriminated-union.test.ts | 90 +++++++++++++++++++ .../src/schemas/discriminated-union/fluent.ts | 3 +- .../src/schemas/discriminated-union/index.ts | 1 + .../resolve-member.test.ts | 59 ++++++++++++ .../discriminated-union/resolve-member.ts | 61 +++++++++++++ .../src/schemas/discriminated-union/types.ts | 19 ++-- packages/dynz/src/schemas/options/types.ts | 2 +- packages/dynz/src/types/schema.ts | 6 +- .../src/utils/find-schema-by-path.test.ts | 51 +++++++++++ .../dynz/src/utils/find-schema-by-path.ts | 4 +- packages/dynz/src/utils/get-nested.test.ts | 47 ++++++++++ packages/dynz/src/utils/get-nested.ts | 12 ++- packages/dynz/src/validate/validate.ts | 16 +++- .../use-discriminated-union-key-values.ts | 56 +++++++++--- 16 files changed, 459 insertions(+), 34 deletions(-) create mode 100644 packages/dynz/src/schemas/discriminated-union/discriminated-union.test.ts create mode 100644 packages/dynz/src/schemas/discriminated-union/resolve-member.test.ts create mode 100644 packages/dynz/src/schemas/discriminated-union/resolve-member.ts diff --git a/packages/dynz/src/conditions/get-condition-dependencies.test.ts b/packages/dynz/src/conditions/get-condition-dependencies.test.ts index d663d24..9fc794c 100644 --- a/packages/dynz/src/conditions/get-condition-dependencies.test.ts +++ b/packages/dynz/src/conditions/get-condition-dependencies.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { and, eq, gt, gte, isIn, isNotIn, lt, lte, matches, neq, or, v } from "../functions"; import { ref } from "../reference"; -import { object, string } from "../schemas"; +import { discriminatedUnion, number, object, string } from "../schemas"; import { getConditionDependencies, getRulesDependenciesMap } from "./get-condition-dependencies"; // Root schema containing all fields referenced in getConditionDependencies tests @@ -258,4 +258,49 @@ describe("getRulesDependenciesMap", () => { }); }); }); + + describe("discriminated union with a DynamicOptionValue discriminator", () => { + // Members here intentionally have no fields besides the discriminator itself, so the only + // possible source of a "$.kind.kind" dependency is the enabled predicate being tested — + // members with other Schema fields independently make the discriminator key depend on + // those fields too (see "tracks per-field dependencies" below). + it("tracks the enabled predicate's references as dependencies of the discriminator key", () => { + const schema = object({ + country: string(), + kind: discriminatedUnion("kind", [ + { kind: { enabled: eq(ref("$.country"), v("NL")), value: "a" } }, + { kind: "b" }, + ]), + }); + + const result = getRulesDependenciesMap(schema, "$"); + + expect(result.dependencies["$.kind.kind"]).toEqual(new Set(["$.country"])); + expect(result.reverse["$.country"]).toContain("$.kind.kind"); + }); + + it("does not add a dependency for a statically enabled/disabled discriminator", () => { + const schema = object({ + kind: discriminatedUnion("kind", [{ kind: { enabled: true, value: "a" } }, { kind: "b" }]), + }); + + const result = getRulesDependenciesMap(schema, "$"); + + expect(result.dependencies["$.kind.kind"]).toBeUndefined(); + }); + + it("also tracks dependencies on the member's other Schema fields, merged with the enabled predicate's", () => { + const schema = object({ + country: string(), + kind: discriminatedUnion("kind", [ + { kind: { enabled: eq(ref("$.country"), v("NL")), value: "a" }, value: string() }, + { kind: "b", value: number() }, + ]), + }); + + const result = getRulesDependenciesMap(schema, "$"); + + expect(result.dependencies["$.kind.kind"]).toEqual(new Set(["$.country", "$.kind.value"])); + }); + }); }); diff --git a/packages/dynz/src/conditions/get-condition-dependencies.ts b/packages/dynz/src/conditions/get-condition-dependencies.ts index ff69bb5..b8625f5 100644 --- a/packages/dynz/src/conditions/get-condition-dependencies.ts +++ b/packages/dynz/src/conditions/get-condition-dependencies.ts @@ -1,6 +1,7 @@ import type { ParamaterValue, Predicate, Transformer } from "../functions"; import { isReference } from "../reference"; import type { Rule } from "../rules"; +import { isDynamicDiscriminatorValue } from "../schemas"; import { type Schema, SchemaType } from "../types"; import { ensureAbsolutePath, findSchemaByPath } from "../utils"; import type { RulesDependencyMap } from "./types"; @@ -117,14 +118,15 @@ function _getRulesDependenciesMap(schema: Schema, path: string, root: Schema): R reverse: {}, }; const addDependencies = (path: string, deps: string[]) => { - if (deps.length > 0) { - result.dependencies[path] = new Set(deps); - } - for (const dep of deps) { + if (!result.dependencies[path]) { + result.dependencies[path] = new Set(); + } if (!result.reverse[dep]) { result.reverse[dep] = new Set(); } + + result.dependencies[path].add(dep); result.reverse[dep].add(path); } }; @@ -162,6 +164,15 @@ function _getRulesDependenciesMap(schema: Schema, path: string, root: Schema): R continue; } + if (isDynamicDiscriminatorValue(fieldSchema)) { + // A predicate-based "enabled" flag means whether this member can match depends on + // other fields — track those as dependencies of the discriminator key itself. + if (typeof fieldSchema.enabled !== "boolean") { + addDependencies(`${path}.${schema.key}`, getConditionDependencies(fieldSchema.enabled, path, root)); + } + continue; + } + // add discriminated union key as a dependency addDependencies(`${path}.${schema.key}`, [`${path}.${fieldKey}`]); diff --git a/packages/dynz/src/schemas/discriminated-union/discriminated-union.test.ts b/packages/dynz/src/schemas/discriminated-union/discriminated-union.test.ts new file mode 100644 index 0000000..53c0325 --- /dev/null +++ b/packages/dynz/src/schemas/discriminated-union/discriminated-union.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { eq, v } from "../../functions"; +import { ref } from "../../reference"; +import { ErrorCode } from "../../types"; +import { validate } from "../../validate"; +import { discriminatedUnion, number, object, string } from ".."; + +describe("discriminated union validation", () => { + const schema = object({ + kind: discriminatedUnion("kind", [ + { kind: "a", value: string() }, + { kind: "b", value: number() }, + ]), + }); + + it("validates the matching member for a plain-string discriminator", async () => { + const result = await validate(schema, undefined, { kind: { kind: "a", value: "hello" } }); + + expect(result).toEqual({ success: true, values: { kind: { kind: "a", value: "hello" } } }); + }); + + it("fails when no member matches the discriminator value", async () => { + const result = await validate(schema, undefined, { kind: { kind: "c", value: "hello" } }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors[0]?.code).toBe(ErrorCode.TYPE); + } + }); + + describe("with a DynamicOptionValue discriminator", () => { + it("matches a member whose discriminator is statically enabled", async () => { + const dynamicSchema = object({ + kind: discriminatedUnion("kind", [ + { kind: { enabled: true, value: "a" }, value: string() }, + { kind: "b", value: number() }, + ]), + }); + + const result = await validate(dynamicSchema, undefined, { kind: { kind: "a", value: "hello" } }); + + expect(result).toEqual({ success: true, values: { kind: { kind: "a", value: "hello" } } }); + }); + + it("unwraps the DynamicOptionValue to its literal value in the validated output", async () => { + const dynamicSchema = object({ + kind: discriminatedUnion("kind", [{ kind: { enabled: true, value: "a" }, value: string() }]), + }); + + const result = await validate(dynamicSchema, undefined, { kind: { kind: "a", value: "hello" } }); + + expect(result).toEqual({ success: true, values: { kind: { kind: "a", value: "hello" } } }); + }); + + it("never matches a member whose discriminator is statically disabled", async () => { + const dynamicSchema = object({ + kind: discriminatedUnion("kind", [ + { kind: { enabled: false, value: "a" }, value: string() }, + { kind: "b", value: number() }, + ]), + }); + + const result = await validate(dynamicSchema, undefined, { kind: { kind: "a", value: "hello" } }); + + expect(result.success).toBe(false); + }); + + it("matches or rejects based on a predicate-based enabled flag", async () => { + const dynamicSchema = object({ + country: string(), + kind: discriminatedUnion("kind", [ + { kind: { enabled: eq(ref("$.country"), v("NL")), value: "a" }, value: string() }, + { kind: "b", value: number() }, + ]), + }); + + const enabledResult = await validate(dynamicSchema, undefined, { + country: "NL", + kind: { kind: "a", value: "hello" }, + }); + expect(enabledResult.success).toBe(true); + + const disabledResult = await validate(dynamicSchema, undefined, { + country: "BE", + kind: { kind: "a", value: "hello" }, + }); + expect(disabledResult.success).toBe(false); + }); + }); +}); diff --git a/packages/dynz/src/schemas/discriminated-union/fluent.ts b/packages/dynz/src/schemas/discriminated-union/fluent.ts index be8da09..f8acfb0 100644 --- a/packages/dynz/src/schemas/discriminated-union/fluent.ts +++ b/packages/dynz/src/schemas/discriminated-union/fluent.ts @@ -1,9 +1,10 @@ import type { Predicate } from "../../functions"; import type { JsonRecord, Schema } from "../../types"; import { SchemaType } from "../../types"; +import type { DynamicOptionValue } from "../options/types"; import type { CheckMember, DiscriminatedUnionSchema } from "./types"; -type SchemaMember = Record; +type SchemaMember = Record; export type DiscriminatedUnionFluent< TKey extends string, diff --git a/packages/dynz/src/schemas/discriminated-union/index.ts b/packages/dynz/src/schemas/discriminated-union/index.ts index 12eb95b..a97283a 100644 --- a/packages/dynz/src/schemas/discriminated-union/index.ts +++ b/packages/dynz/src/schemas/discriminated-union/index.ts @@ -1,2 +1,3 @@ export * from "./fluent"; +export * from "./resolve-member"; export * from "./types"; diff --git a/packages/dynz/src/schemas/discriminated-union/resolve-member.test.ts b/packages/dynz/src/schemas/discriminated-union/resolve-member.test.ts new file mode 100644 index 0000000..af028c4 --- /dev/null +++ b/packages/dynz/src/schemas/discriminated-union/resolve-member.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { eq, v } from "../../functions"; +import { ref } from "../../reference"; +import type { Schema } from "../../types"; +import { object, string } from ".."; +import { getDiscriminatorLiteral, isDiscriminatorEnabled, isDynamicDiscriminatorValue } from "./resolve-member"; + +const rootSchema = object({ + country: string(), + kind: string(), +}); + +describe("isDynamicDiscriminatorValue", () => { + it("returns false for plain primitives", () => { + expect(isDynamicDiscriminatorValue("a")).toBe(false); + expect(isDynamicDiscriminatorValue(1)).toBe(false); + expect(isDynamicDiscriminatorValue(true)).toBe(false); + }); + + it("returns false for a Schema (it has a type field)", () => { + expect(isDynamicDiscriminatorValue(string() as Schema)).toBe(false); + }); + + it("returns true for a DynamicOptionValue", () => { + expect(isDynamicDiscriminatorValue({ enabled: true, value: "a" })).toBe(true); + expect(isDynamicDiscriminatorValue({ enabled: eq(ref("country"), v("NL")), value: "a" })).toBe(true); + }); +}); + +describe("getDiscriminatorLiteral", () => { + it("returns plain primitives as-is", () => { + expect(getDiscriminatorLiteral("a")).toBe("a"); + expect(getDiscriminatorLiteral(1)).toBe(1); + expect(getDiscriminatorLiteral(false)).toBe(false); + }); + + it("unwraps a DynamicOptionValue to its literal value", () => { + expect(getDiscriminatorLiteral({ enabled: true, value: "a" })).toBe("a"); + expect(getDiscriminatorLiteral({ enabled: false, value: 2 })).toBe(2); + }); +}); + +describe("isDiscriminatorEnabled", () => { + it("is always enabled for plain primitives", () => { + expect(isDiscriminatorEnabled("a", "$", { schema: rootSchema, values: {} })).toBe(true); + }); + + it("resolves a static boolean enabled flag", () => { + expect(isDiscriminatorEnabled({ enabled: true, value: "a" }, "$", { schema: rootSchema, values: {} })).toBe(true); + expect(isDiscriminatorEnabled({ enabled: false, value: "a" }, "$", { schema: rootSchema, values: {} })).toBe(false); + }); + + it("resolves a predicate-based enabled flag against the given values", () => { + const entry = { enabled: eq(ref("country"), v("NL")), value: "a" }; + + expect(isDiscriminatorEnabled(entry, "$", { schema: rootSchema, values: { country: "NL" } })).toBe(true); + expect(isDiscriminatorEnabled(entry, "$", { schema: rootSchema, values: { country: "BE" } })).toBe(false); + }); +}); diff --git a/packages/dynz/src/schemas/discriminated-union/resolve-member.ts b/packages/dynz/src/schemas/discriminated-union/resolve-member.ts new file mode 100644 index 0000000..58aa50a --- /dev/null +++ b/packages/dynz/src/schemas/discriminated-union/resolve-member.ts @@ -0,0 +1,61 @@ +import { resolvePredicate } from "../../functions"; +import type { ResolveContext, Schema } from "../../types"; +import { isBoolean, isNumber, isString } from "../../validate/validate-type"; +import type { DynamicOptionValue } from "../options/types"; + +/** + * Whether a discriminated union member's raw entry is a `DynamicOptionValue` + * (`{ value, enabled }`) rather than a plain primitive or a nested `Schema`. + * `Schema` objects always carry a `type` discriminant, which `DynamicOptionValue` + * never does. + */ +export function isDynamicDiscriminatorValue( + value: Schema | string | number | boolean | DynamicOptionValue +): value is DynamicOptionValue { + return typeof value === "object" && value !== null && !("type" in value); +} + +/** + * Unwraps a discriminator entry to its literal value, regardless of whether + * it's a plain primitive or a `DynamicOptionValue`. Only ever called on the + * discriminator key's entry, which is never actually a `Schema` at runtime + * (accepting the full `DiscriminatorEntry` type just avoids casts at call + * sites, since member entries are generically typed as `Schema | ...`). + */ +export function getDiscriminatorLiteral( + value: Schema | string | number | boolean | DynamicOptionValue +): string | number | boolean | undefined { + if (isDynamicDiscriminatorValue(value)) { + return value.value; + } + + if (isString(value) || isNumber(value) || isBoolean(value)) { + return value; + } + + return undefined; +} + +/** + * Whether a discriminator entry is currently enabled. Plain primitives are + * always enabled; `DynamicOptionValue` entries resolve their `enabled` flag + * (boolean or Predicate) the same way `options()` does (see `isOption` in + * `validate/validate-type.ts`). + */ +export function isDiscriminatorEnabled( + value: Schema | string | number | boolean | DynamicOptionValue, + path: string, + context: ResolveContext +): boolean { + if (!isDynamicDiscriminatorValue(value)) { + return true; + } + + if (typeof value.enabled === "boolean") { + return value.enabled; + } + + const resolved = resolvePredicate(value.enabled, path, context); + + return resolved === undefined ? false : resolved; +} diff --git a/packages/dynz/src/schemas/discriminated-union/types.ts b/packages/dynz/src/schemas/discriminated-union/types.ts index 5e05ac8..a41a366 100644 --- a/packages/dynz/src/schemas/discriminated-union/types.ts +++ b/packages/dynz/src/schemas/discriminated-union/types.ts @@ -1,19 +1,24 @@ import type { BaseSchema, Schema, SchemaType } from "../../types"; +import type { DynamicOptionValue } from "../options/types"; -// Per-element validation type: discriminator key accepts primitives, all other keys must be Schema. -// Used as a self-referential constraint in discriminatedUnion() so TypeScript checks each key -// individually rather than collapsing the condition into a single index signature. +// Per-element validation type: discriminator key accepts primitives or a DynamicOptionValue +// (to conditionally enable/disable that member, exactly like an options() value), all other +// keys must be Schema. Used as a self-referential constraint in discriminatedUnion() so +// TypeScript checks each key individually rather than collapsing the condition into a single +// index signature. export type CheckMember = { - [K in keyof T]: K extends TKey ? string | number | boolean : T[K] extends Schema ? T[K] : never; + [K in keyof T]: K extends TKey ? string | number | boolean | DynamicOptionValue : T[K] extends Schema ? T[K] : never; }; export type DiscriminatedUnionSchema< TKey extends string = string, - TSchemas extends Record[] = Record< + TSchemas extends Record[] = Record< string, - Schema | string | number | boolean + Schema | string | number | boolean | DynamicOptionValue >[], > = BaseSchema & { key: TKey; - schemas: [TSchemas] extends [never] ? Record[] : TSchemas; + schemas: [TSchemas] extends [never] + ? Record[] + : TSchemas; }; diff --git a/packages/dynz/src/schemas/options/types.ts b/packages/dynz/src/schemas/options/types.ts index c40207f..9cad722 100644 --- a/packages/dynz/src/schemas/options/types.ts +++ b/packages/dynz/src/schemas/options/types.ts @@ -3,7 +3,7 @@ import type { Rule } from "../../rules"; import type { Unpacked } from "./../../types"; import type { BaseSchema, SchemaType } from "../../types"; -export type DynamicOptionValue = { enabled: Predicate | boolean; value: OptionValue }; +export type DynamicOptionValue = { enabled: Predicate | boolean; value: string | number | boolean }; export type OptionValue = string | number | boolean | DynamicOptionValue; export type OptionsValue = OptionValue[]; diff --git a/packages/dynz/src/types/schema.ts b/packages/dynz/src/types/schema.ts index 5f7371f..9fdb3bf 100644 --- a/packages/dynz/src/types/schema.ts +++ b/packages/dynz/src/types/schema.ts @@ -143,11 +143,11 @@ export type ObjectValue> = OptionalFields & Req type DiscriminatedMemberValue< TKey extends string, - TMember extends Record, -> = TMember extends Record + TMember extends Record, +> = TMember extends Record ? { [K in keyof TMember as K extends TKey ? K : TMember[K] extends Schema ? K : never]: K extends TKey - ? TMember[K] + ? UnwrapOptionValue : TMember[K] extends Schema ? SchemaValuesInternal : never; diff --git a/packages/dynz/src/utils/find-schema-by-path.test.ts b/packages/dynz/src/utils/find-schema-by-path.test.ts index 136b6dd..7e4cded 100644 --- a/packages/dynz/src/utils/find-schema-by-path.test.ts +++ b/packages/dynz/src/utils/find-schema-by-path.test.ts @@ -237,4 +237,55 @@ describe("findSchemaByPath", () => { expect(result).toEqual(schema); }); }); + + describe("discriminated union traversal", () => { + it("should return the union schema itself for a plain-primitive discriminator key path", () => { + const unionSchema = { + type: SchemaType.DISCRIMINATED_UNION, + key: "kind", + schemas: [ + { kind: "a", value: { type: SchemaType.STRING } }, + { kind: "b", value: { type: SchemaType.NUMBER } }, + ], + }; + const schema = { type: SchemaType.OBJECT, fields: { union: unionSchema } }; + + const result = findSchemaByPath("$.union.kind", schema); + + expect(result).toEqual(unionSchema); + }); + + it("should return the union schema itself for a DynamicOptionValue discriminator key path", () => { + const unionSchema = { + type: SchemaType.DISCRIMINATED_UNION, + key: "kind", + schemas: [ + { kind: { enabled: true, value: "a" }, value: { type: SchemaType.STRING } }, + { kind: "b", value: { type: SchemaType.NUMBER } }, + ], + }; + const schema = { type: SchemaType.OBJECT, fields: { union: unionSchema } }; + + const result = findSchemaByPath("$.union.kind", schema); + + expect(result).toEqual(unionSchema); + }); + + it("should find a member's field schema", () => { + const schema = { + type: SchemaType.OBJECT, + fields: { + union: { + type: SchemaType.DISCRIMINATED_UNION, + key: "kind", + schemas: [{ kind: { enabled: true, value: "a" }, value: { type: SchemaType.STRING } }], + }, + }, + }; + + const result = findSchemaByPath("$.union.value", schema); + + expect(result).toEqual({ type: SchemaType.STRING }); + }); + }); }); diff --git a/packages/dynz/src/utils/find-schema-by-path.ts b/packages/dynz/src/utils/find-schema-by-path.ts index 94f82db..9f75598 100644 --- a/packages/dynz/src/utils/find-schema-by-path.ts +++ b/packages/dynz/src/utils/find-schema-by-path.ts @@ -1,3 +1,4 @@ +import { isDynamicDiscriminatorValue } from "../schemas"; import { type Schema, SchemaType } from "../types"; import { isNumber } from "../validate/validate-type"; @@ -40,7 +41,8 @@ export function findSchemaByPath(path: string, schema if ( typeof childSchema === "boolean" || typeof childSchema === "number" || - typeof childSchema === "string" + typeof childSchema === "string" || + isDynamicDiscriminatorValue(childSchema) ) { return prev; } diff --git a/packages/dynz/src/utils/get-nested.test.ts b/packages/dynz/src/utils/get-nested.test.ts index 1e70926..0fcc1c8 100644 --- a/packages/dynz/src/utils/get-nested.test.ts +++ b/packages/dynz/src/utils/get-nested.test.ts @@ -310,4 +310,51 @@ describe("getNested", () => { }); }); }); + + describe("discriminated union traversal", () => { + const memberValueSchema = { type: SchemaType.STRING }; + + it("should traverse into the matching member for a plain-primitive discriminator", () => { + const schema = { + type: SchemaType.DISCRIMINATED_UNION, + key: "kind", + schemas: [ + { kind: "a", value: memberValueSchema }, + { kind: "b", value: { type: SchemaType.NUMBER } }, + ], + }; + const value = { kind: "a", value: "hello" }; + + const result = getNested("$.value", schema, value); + + expect(result).toEqual({ schema: memberValueSchema, value: "hello" }); + }); + + it("should traverse into the matching member for a DynamicOptionValue discriminator", () => { + const schema = { + type: SchemaType.DISCRIMINATED_UNION, + key: "kind", + schemas: [ + { kind: { enabled: true, value: "a" }, value: memberValueSchema }, + { kind: "b", value: { type: SchemaType.NUMBER } }, + ], + }; + const value = { kind: "a", value: "hello" }; + + const result = getNested("$.value", schema, value); + + expect(result).toEqual({ schema: memberValueSchema, value: "hello" }); + }); + + it("should return null when no member matches the discriminator value", () => { + const schema = { + type: SchemaType.DISCRIMINATED_UNION, + key: "kind", + schemas: [{ kind: { enabled: true, value: "a" }, value: memberValueSchema }], + }; + const value = { kind: "unknown", value: "hello" }; + + expect(getNested("$.value", schema, value)).toBeNull(); + }); + }); }); diff --git a/packages/dynz/src/utils/get-nested.ts b/packages/dynz/src/utils/get-nested.ts index 06d990c..f533c13 100644 --- a/packages/dynz/src/utils/get-nested.ts +++ b/packages/dynz/src/utils/get-nested.ts @@ -1,3 +1,4 @@ +import { getDiscriminatorLiteral, isDynamicDiscriminatorValue } from "../schemas"; import { type Schema, SchemaType } from "../types"; import { isObject } from "../validate/validate-type"; import { coerceSchema } from "./coerce"; @@ -68,7 +69,7 @@ export function getNested( const { key } = acc.schema; const discriminatorValue = acc.value[key]; - const matchingMember = acc.schema.schemas.find((s) => s[key] === discriminatorValue); + const matchingMember = acc.schema.schemas.find((s) => getDiscriminatorLiteral(s[key]) === discriminatorValue); if (matchingMember === undefined) { return null; @@ -80,7 +81,12 @@ export function getNested( return null; } - if (typeof childSchema === "string" || typeof childSchema === "number" || typeof childSchema === "boolean") { + if ( + typeof childSchema === "string" || + typeof childSchema === "number" || + typeof childSchema === "boolean" || + isDynamicDiscriminatorValue(childSchema) + ) { return { value: isObject(acc.value) ? acc.value[acc.schema.key] : undefined, schema: acc.schema, @@ -88,7 +94,7 @@ export function getNested( } return { - value: acc.value, + value: acc.value[cur], schema: childSchema, }; } diff --git a/packages/dynz/src/validate/validate.ts b/packages/dynz/src/validate/validate.ts index f80b8b3..f2dc872 100644 --- a/packages/dynz/src/validate/validate.ts +++ b/packages/dynz/src/validate/validate.ts @@ -2,6 +2,7 @@ import { _resolveProperty, resolveProperty, resolveRules } from "../conditions"; import { resolve } from "../functions"; import { isPivateValue, isValueMasked, type PrivateValue } from "../private"; import { validateRule } from "../rules"; +import { getDiscriminatorLiteral, isDiscriminatorEnabled, isDynamicDiscriminatorValue } from "../schemas"; import { type Context, ErrorCode, @@ -340,7 +341,11 @@ export async function _validate( } const discriminatorValue = newValue[schema.key]; - const matchingMember = schema.schemas.find((s) => s[schema.key] === discriminatorValue); + const matchingMember = schema.schemas.find( + (s) => + getDiscriminatorLiteral(s[schema.key]) === discriminatorValue && + isDiscriminatorEnabled(s[schema.key], path, context) + ); if (matchingMember === undefined) { return { @@ -362,12 +367,17 @@ export async function _validate( const entries = await Promise.all( Object.entries(matchingMember).map(async ([key, innerSchema]) => { - if (typeof innerSchema === "string" || typeof innerSchema === "boolean" || typeof innerSchema === "number") { + if ( + typeof innerSchema === "string" || + typeof innerSchema === "boolean" || + typeof innerSchema === "number" || + isDynamicDiscriminatorValue(innerSchema) + ) { return { key, result: { success: true, - values: innerSchema, + values: isDynamicDiscriminatorValue(innerSchema) ? innerSchema.value : innerSchema, } as ValidationSuccesResult, }; } diff --git a/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts b/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts index c599323..e3f440a 100644 --- a/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts +++ b/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts @@ -1,18 +1,54 @@ -import { type DiscriminatedUnionSchema, findSchemaByPath, SchemaType } from "dynz"; -import { useMemo } from "react"; +import { + type DiscriminatedUnionSchema, + findSchemaByPath, + getConditionDependencies, + getDiscriminatorLiteral, + isDynamicDiscriminatorValue, + resolvePredicate, + SchemaType, +} from "dynz"; +import { useWatch } from "react-hook-form"; import { useDynzFormContext } from "./use-dynz-form-context"; export function useDiscriminatedUnionKeyValues(name: string) { - const { schema } = useDynzFormContext(); + const { control, getValues, schema } = useDynzFormContext(); + // TODO: memoize const unionSchema = findSchemaByPath(`$.${name}`, schema, SchemaType.DISCRIMINATED_UNION); - return useMemo(() => { - const key = unionSchema.key; + // TODO: memoize + const dependencies = unionSchema.schemas.reduce((acc, member) => { + const raw = member[unionSchema.key]; + if (isDynamicDiscriminatorValue(raw) && typeof raw.enabled !== "boolean") { + acc.push(...getConditionDependencies(raw.enabled, "$", schema)); + } + return acc; + }, []); - return unionSchema.schemas.map((member) => ({ - enabled: true, - value: member[key] as string | number | boolean, - })); - }, [unionSchema]); + // Watch is just here to trigger a rerender when a value gets updated + useWatch({ + name: dependencies.map((dep) => dep.slice(2)), + control, + }); + + const values = getValues(); + + return unionSchema.schemas.map((member) => { + const raw = member[unionSchema.key]; + + if (!isDynamicDiscriminatorValue(raw)) { + return { + enabled: true, + value: raw as string | number | boolean, + }; + } + + const enabled = + typeof raw.enabled === "boolean" ? raw.enabled : resolvePredicate(raw.enabled, "$", { schema, values }); + + return { + enabled: Boolean(enabled), + value: getDiscriminatorLiteral(raw), + }; + }); } From 3d8964527c0eed1fff81bf5c3513bee60224bfc8 Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Thu, 9 Jul 2026 09:51:59 +0200 Subject: [PATCH 2/4] fix: fixed small issue in resolving property dependencies --- .changeset/fresh-llamas-strive.md | 6 ++++++ .../src/hooks/use-conditional-property.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 .changeset/fresh-llamas-strive.md diff --git a/.changeset/fresh-llamas-strive.md b/.changeset/fresh-llamas-strive.md new file mode 100644 index 0000000..c041c44 --- /dev/null +++ b/.changeset/fresh-llamas-strive.md @@ -0,0 +1,6 @@ +--- +"@dynz/react-hook-form": patch +"dynz": patch +--- + +added dynamic discriminator keys diff --git a/packages/react-hook-form/src/hooks/use-conditional-property.ts b/packages/react-hook-form/src/hooks/use-conditional-property.ts index 3eb49f7..7c9af6f 100644 --- a/packages/react-hook-form/src/hooks/use-conditional-property.ts +++ b/packages/react-hook-form/src/hooks/use-conditional-property.ts @@ -11,10 +11,22 @@ function getPropertyDependencies( schema: Parameters[1] ): string[] { const segments = path.split(/[.[\]]/).filter(Boolean); + let previousAncestor: ReturnType | undefined; + return segments.flatMap((_, i) => { const ancestorPath = segments.slice(0, i + 1).join("."); const ancestor = findSchemaByPath(ancestorPath, schema); + // A discriminated union's key-path alias (e.g. "$.left_side.type") resolves to the + // SAME schema object as its own path ("$.left_side"), since the key isn't a real + // nested field. Skip reprocessing it here: its included/required/mutable predicate + // was already handled at its true (shallower) path, and reprocessing it here would + // resolve any relative refs it contains against the wrong (deeper) base path. + if (ancestor === previousAncestor) { + return []; + } + previousAncestor = ancestor; + // When ancestor is a discriminuted union the 'key' of the union // must always be added as an implicit dependency const dependencies = From be8a8a94c054c75112350bf4c43327fb742b6c6b Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Thu, 9 Jul 2026 22:58:05 +0200 Subject: [PATCH 3/4] fix: fixed small issue in resolving property dependencies --- examples/next-example/messages/en.json | 3 + .../app/[locale]/discriminated-union/page.tsx | 11 ++- .../src/components/dynz/union-key.tsx | 6 +- .../get-condition-dependencies.test.ts | 25 ++++++ .../conditions/get-condition-dependencies.ts | 25 ++++-- .../dynz/src/conditions/resolve-property.ts | 11 ++- packages/dynz/src/functions/resolve.ts | 11 ++- .../discriminated-union.test.ts | 23 +++++ .../discriminated-union/discriminator.test.ts | 55 ++++++++++++ .../discriminated-union/discriminator.ts | 26 ++++++ .../src/schemas/discriminated-union/fluent.ts | 42 +++++++++- .../src/schemas/discriminated-union/index.ts | 2 +- .../resolve-member.test.ts | 59 ------------- .../discriminated-union/resolve-member.ts | 61 -------------- .../src/schemas/discriminated-union/types.ts | 52 ++++++++++-- packages/dynz/src/types/schema.ts | 9 +- .../src/utils/find-schema-by-path.test.ts | 84 ++++++++++++++----- .../dynz/src/utils/find-schema-by-path.ts | 67 +++++++++++---- packages/dynz/src/utils/get-nested.test.ts | 15 ++-- packages/dynz/src/utils/get-nested.ts | 74 +++++++++------- packages/dynz/src/validate/validate.ts | 24 +++--- .../src/hooks/use-conditional-property.ts | 15 ++-- .../use-discriminated-union-key-values.ts | 34 ++++---- 23 files changed, 471 insertions(+), 263 deletions(-) create mode 100644 packages/dynz/src/schemas/discriminated-union/discriminator.test.ts create mode 100644 packages/dynz/src/schemas/discriminated-union/discriminator.ts delete mode 100644 packages/dynz/src/schemas/discriminated-union/resolve-member.test.ts delete mode 100644 packages/dynz/src/schemas/discriminated-union/resolve-member.ts diff --git a/examples/next-example/messages/en.json b/examples/next-example/messages/en.json index e119d72..7ec4ef4 100644 --- a/examples/next-example/messages/en.json +++ b/examples/next-example/messages/en.json @@ -8,6 +8,9 @@ "label": "Naam", "placeholder": "Bijv. Jan" }, + "allowPhone": { + "label": "Allow phone contact" + }, "contactDetails": { "type": { "label": "Type" diff --git a/examples/next-example/src/app/[locale]/discriminated-union/page.tsx b/examples/next-example/src/app/[locale]/discriminated-union/page.tsx index 1fba3ec..4fb488a 100644 --- a/examples/next-example/src/app/[locale]/discriminated-union/page.tsx +++ b/examples/next-example/src/app/[locale]/discriminated-union/page.tsx @@ -1,21 +1,27 @@ "use client"; -import { discriminatedUnion, literal, object, ref, string } from "dynz"; +import { boolean, discriminatedUnion, eq, object, ref, string, v } from "dynz"; +import { DynzCheckbox } from "@/components/dynz/checkbox"; import { DynzForm } from "@/components/dynz/form"; import { DynzInput } from "@/components/dynz/input"; import { DynzUnionKey } from "@/components/dynz/union-key"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +// "phone" is a valid contact method only when the user has opted in via +// `allowPhone`. Its discriminator entry is a `DynamicOptionValue` (`{ value, enabled }`) +// instead of a plain string, so the union member itself is enabled/disabled based on +// another field, exactly like an `options()` value. const schema = object({ name: string(), + allowPhone: boolean(), contactDetails: discriminatedUnion("type", [ { type: "email", email: string(), }, { - type: "phone", + type: { value: "phone", enabled: eq(ref("allowPhone"), v(true)) }, phone: string(), }, ]), @@ -27,6 +33,7 @@ export default function Home() { + diff --git a/examples/next-example/src/components/dynz/union-key.tsx b/examples/next-example/src/components/dynz/union-key.tsx index 17c7b65..7477bb9 100644 --- a/examples/next-example/src/components/dynz/union-key.tsx +++ b/examples/next-example/src/components/dynz/union-key.tsx @@ -28,7 +28,11 @@ export function DynzUnionKey({ name }: DynzSelectProps) { {options.map((option) => ( - + {option.value?.toString()} ))} diff --git a/packages/dynz/src/conditions/get-condition-dependencies.test.ts b/packages/dynz/src/conditions/get-condition-dependencies.test.ts index 9fc794c..366e73a 100644 --- a/packages/dynz/src/conditions/get-condition-dependencies.test.ts +++ b/packages/dynz/src/conditions/get-condition-dependencies.test.ts @@ -303,4 +303,29 @@ describe("getRulesDependenciesMap", () => { expect(result.dependencies["$.kind.kind"]).toEqual(new Set(["$.country", "$.kind.value"])); }); }); + + describe("a ref() pointing at a discriminator key path", () => { + it("includes the ref itself plus the union's own included predicate's dependencies", () => { + const schema = object({ + country: string(), + union: discriminatedUnion("kind", [{ kind: "a", value: string() }]).setIncluded(eq(ref("country"), v("NL"))), + }); + + const condition = eq(ref("union.kind"), v("a")); + const result = getConditionDependencies(condition, "$", schema); + + expect(result).toEqual(["$.union.kind", "$.country"]); + }); + + it("only includes the ref itself when the union has no conditional included predicate", () => { + const schema = object({ + union: discriminatedUnion("kind", [{ kind: "a", value: string() }]), + }); + + const condition = eq(ref("union.kind"), v("a")); + const result = getConditionDependencies(condition, "$", schema); + + expect(result).toEqual(["$.union.kind"]); + }); + }); }); diff --git a/packages/dynz/src/conditions/get-condition-dependencies.ts b/packages/dynz/src/conditions/get-condition-dependencies.ts index b8625f5..6005572 100644 --- a/packages/dynz/src/conditions/get-condition-dependencies.ts +++ b/packages/dynz/src/conditions/get-condition-dependencies.ts @@ -1,9 +1,9 @@ import type { ParamaterValue, Predicate, Transformer } from "../functions"; import { isReference } from "../reference"; import type { Rule } from "../rules"; -import { isDynamicDiscriminatorValue } from "../schemas"; +import { isDiscriminator } from "../schemas"; import { type Schema, SchemaType } from "../types"; -import { ensureAbsolutePath, findSchemaByPath } from "../utils"; +import { ensureAbsolutePath, findSchemaByPath, isDiscriminatorKey } from "../utils"; import type { RulesDependencyMap } from "./types"; /** @@ -62,9 +62,20 @@ export function getConditionDependencies(input: Predicate | Transformer, path: s export function getParamaterDependencies(param: ParamaterValue, path: string, schema: Schema): string[] { if (isReference(param)) { const referencePath = ensureAbsolutePath(param.path, path); - const inner = findSchemaByPath(referencePath, schema); + if (isDiscriminatorKey(inner)) { + // A discriminated union's discriminator-key path (e.g. "$.left_side.type") isn't a real + // nested field — its included/required/mutable predicate is anchored to the union's own + // (shallower) path, so recursing with the alias path would resolve any relative refs it + // contains against the wrong (deeper) base path. + const ownPath = referencePath.split(".").slice(0, -1).join("."); + + return inner.included !== undefined && typeof inner.included !== "boolean" + ? [referencePath, ...getConditionDependencies(inner.included, ownPath, schema)] + : [referencePath]; + } + if (inner.included !== undefined && typeof inner.included !== "boolean") { return [referencePath, ...getConditionDependencies(inner.included, referencePath, schema)]; } @@ -160,14 +171,10 @@ function _getRulesDependenciesMap(schema: Schema, path: string, root: Schema): R case SchemaType.DISCRIMINATED_UNION: { for (const member of schema.schemas) { for (const [fieldKey, fieldSchema] of Object.entries(member)) { - if (typeof fieldSchema === "string" || typeof fieldSchema === "number" || typeof fieldSchema === "boolean") { - continue; - } - - if (isDynamicDiscriminatorValue(fieldSchema)) { + if (isDiscriminator(fieldSchema)) { // A predicate-based "enabled" flag means whether this member can match depends on // other fields — track those as dependencies of the discriminator key itself. - if (typeof fieldSchema.enabled !== "boolean") { + if (fieldSchema.enabled !== undefined && typeof fieldSchema.enabled !== "boolean") { addDependencies(`${path}.${schema.key}`, getConditionDependencies(fieldSchema.enabled, path, root)); } continue; diff --git a/packages/dynz/src/conditions/resolve-property.ts b/packages/dynz/src/conditions/resolve-property.ts index 1328e85..0072dba 100644 --- a/packages/dynz/src/conditions/resolve-property.ts +++ b/packages/dynz/src/conditions/resolve-property.ts @@ -1,6 +1,6 @@ import { type Predicate, resolvePredicate } from "../functions"; import type { ResolveContext, Schema } from "../types"; -import { getNested } from "../utils"; +import { getNested, isDiscriminatorKey } from "../utils"; export function resolveProperty( property: "required" | "mutable" | "included", @@ -27,6 +27,15 @@ export function resolveProperty( return false; } + // A discriminated union's key-path alias (e.g. "$.left_side.type") isn't a real nested + // field — its included/required/mutable predicate is anchored to the union's own + // (shallower) path, which was already processed one iteration earlier in this same loop. + // Reprocessing it here would resolve any relative refs it contains against the wrong + // (deeper) base path. + if (isDiscriminatorKey(nested.schema)) { + continue; + } + const ret = _resolveProperty(nested.schema, property, currentPath, defaultValue, context); if (ret === false) { diff --git a/packages/dynz/src/functions/resolve.ts b/packages/dynz/src/functions/resolve.ts index 3024c78..d248db0 100644 --- a/packages/dynz/src/functions/resolve.ts +++ b/packages/dynz/src/functions/resolve.ts @@ -1,7 +1,7 @@ import { isIncluded } from "../conditions"; import type { Reference } from "../reference"; import type { ResolveContext, Schema, SchemaType, ValueType } from "../types"; -import { coerce, coerceSchema, ensureAbsolutePath, getNested } from "../utils"; +import { coerce, coerceSchema, ensureAbsolutePath, getNested, isDiscriminatorKey } from "../utils"; import { validateShallowType, validateType } from "../validate/validate-type"; import type { Predicate } from "./predicate-types"; import { PREDICATES } from "./predicates"; @@ -34,6 +34,15 @@ export function unpackRef( return undefined; } + // A discriminator key (e.g. "$.union.kind") has no single canonical Schema to coerce/validate + // against — instead its value is only ever one of the union's own declared discriminator + // values, so check membership directly rather than running it through coerceSchema/validateType. + if (isDiscriminatorKey(schema)) { + return schema.discriminators.find((discriminator) => discriminator.value === value)?.value as + | ValueType + | undefined; + } + if (schema.type === "expression") { return resolve(schema.value, absolutePath, context) as ValueType; } diff --git a/packages/dynz/src/schemas/discriminated-union/discriminated-union.test.ts b/packages/dynz/src/schemas/discriminated-union/discriminated-union.test.ts index 53c0325..0d78ecf 100644 --- a/packages/dynz/src/schemas/discriminated-union/discriminated-union.test.ts +++ b/packages/dynz/src/schemas/discriminated-union/discriminated-union.test.ts @@ -4,6 +4,29 @@ import { ref } from "../../reference"; import { ErrorCode } from "../../types"; import { validate } from "../../validate"; import { discriminatedUnion, number, object, string } from ".."; +import { DISCRIMINATOR_TYPE } from "./types"; + +describe("discriminatedUnion()", () => { + it("normalizes a raw primitive discriminator value into a Discriminator", () => { + const schema = discriminatedUnion("kind", [{ kind: "a", value: string() }]); + + expect(schema.schemas[0]?.kind).toEqual({ type: DISCRIMINATOR_TYPE, value: "a" }); + }); + + it("normalizes a DynamicOptionValue discriminator value into a Discriminator", () => { + const enabled = eq(ref("country"), v("NL")); + const schema = discriminatedUnion("kind", [{ kind: { enabled, value: "a" }, value: string() }]); + + expect(schema.schemas[0]?.kind).toEqual({ type: DISCRIMINATOR_TYPE, value: "a", enabled }); + }); + + it("leaves non-discriminator fields untouched", () => { + const valueSchema = string(); + const schema = discriminatedUnion("kind", [{ kind: "a", value: valueSchema }]); + + expect(schema.schemas[0]?.value).toBe(valueSchema); + }); +}); describe("discriminated union validation", () => { const schema = object({ diff --git a/packages/dynz/src/schemas/discriminated-union/discriminator.test.ts b/packages/dynz/src/schemas/discriminated-union/discriminator.test.ts new file mode 100644 index 0000000..370b840 --- /dev/null +++ b/packages/dynz/src/schemas/discriminated-union/discriminator.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { eq, v } from "../../functions"; +import { ref } from "../../reference"; +import { object, string } from ".."; +import { isDiscriminatorEnabled, isDiscriminator } from "./discriminator"; +import { DISCRIMINATOR_TYPE, type Discriminator } from "./types"; + +const rootSchema = object({ + country: string(), +}); + +describe("isDiscriminator", () => { + it("returns false for a regular Schema", () => { + expect(isDiscriminator(string())).toBe(false); + }); + + it("returns true for a Discriminator", () => { + expect(isDiscriminator({ type: DISCRIMINATOR_TYPE, value: "a" })).toBe(true); + expect(isDiscriminator({ type: DISCRIMINATOR_TYPE, value: "a", enabled: eq(ref("country"), v("NL")) })).toBe(true); + }); +}); + +describe("isDiscriminatorEnabled", () => { + it("is enabled when `enabled` is omitted", () => { + const discriminator: Discriminator = { type: DISCRIMINATOR_TYPE, value: "a" }; + + expect(isDiscriminatorEnabled(discriminator, "$", { schema: rootSchema, values: {} })).toBe(true); + }); + + it("resolves a static boolean enabled flag", () => { + expect( + isDiscriminatorEnabled({ type: DISCRIMINATOR_TYPE, value: "a", enabled: true }, "$", { + schema: rootSchema, + values: {}, + }) + ).toBe(true); + expect( + isDiscriminatorEnabled({ type: DISCRIMINATOR_TYPE, value: "a", enabled: false }, "$", { + schema: rootSchema, + values: {}, + }) + ).toBe(false); + }); + + it("resolves a predicate-based enabled flag against the given values", () => { + const discriminator: Discriminator = { + type: DISCRIMINATOR_TYPE, + value: "a", + enabled: eq(ref("country"), v("NL")), + }; + + expect(isDiscriminatorEnabled(discriminator, "$", { schema: rootSchema, values: { country: "NL" } })).toBe(true); + expect(isDiscriminatorEnabled(discriminator, "$", { schema: rootSchema, values: { country: "BE" } })).toBe(false); + }); +}); diff --git a/packages/dynz/src/schemas/discriminated-union/discriminator.ts b/packages/dynz/src/schemas/discriminated-union/discriminator.ts new file mode 100644 index 0000000..e2ebf49 --- /dev/null +++ b/packages/dynz/src/schemas/discriminated-union/discriminator.ts @@ -0,0 +1,26 @@ +import { resolvePredicate } from "../../functions"; +import type { ResolveContext, Schema } from "../../types"; +import { DISCRIMINATOR_TYPE, type Discriminator } from "./types"; + +/** + * Whether a discriminated union member's entry is the discriminator key's `Discriminator` + * rather than a regular `Schema` field. A positive check on `.type`, mirroring how every other + * schema kind is distinguished — `Discriminator` is deliberately not part of the `Schema` + * union, so this is the one place that has to look. + */ +export function isDiscriminator(value: Schema | Discriminator): value is Discriminator { + return value.type === DISCRIMINATOR_TYPE; +} + +/** + * Whether a discriminator is currently enabled. `enabled: undefined` (or omitted) means always + * enabled; a boolean is used as-is; a `Predicate` is resolved the same way `options()` resolves + * a `DynamicOptionValue["enabled"]` (see `isOption` in `validate/validate-type.ts`). + */ +export function isDiscriminatorEnabled(discriminator: Discriminator, path: string, context: ResolveContext): boolean { + if (discriminator.enabled === undefined || typeof discriminator.enabled === "boolean") { + return discriminator.enabled ?? true; + } + + return resolvePredicate(discriminator.enabled, path, context) ?? false; +} diff --git a/packages/dynz/src/schemas/discriminated-union/fluent.ts b/packages/dynz/src/schemas/discriminated-union/fluent.ts index f8acfb0..a0a2a1f 100644 --- a/packages/dynz/src/schemas/discriminated-union/fluent.ts +++ b/packages/dynz/src/schemas/discriminated-union/fluent.ts @@ -2,9 +2,31 @@ import type { Predicate } from "../../functions"; import type { JsonRecord, Schema } from "../../types"; import { SchemaType } from "../../types"; import type { DynamicOptionValue } from "../options/types"; -import type { CheckMember, DiscriminatedUnionSchema } from "./types"; +import { + type CheckMember, + type DiscriminatedUnionSchema, + DISCRIMINATOR_TYPE, + type Discriminator, + type NormalizeMember, +} from "./types"; -type SchemaMember = Record; +type SchemaMember = Record; + +type NormalizeMembers = { + [I in keyof TSchemas]: NormalizeMember; +}; + +/** + * Normalizes an authoring-time discriminator value (a raw primitive or a `DynamicOptionValue`, + * exactly like an `options()` value) into the stored `Discriminator` shape. + */ +function normalizeDiscriminatorEntry(raw: string | number | boolean | DynamicOptionValue): Discriminator { + if (typeof raw === "object") { + return { type: DISCRIMINATOR_TYPE, value: raw.value, enabled: raw.enabled }; + } + + return { type: DISCRIMINATOR_TYPE, value: raw }; +} export type DiscriminatedUnionFluent< TKey extends string, @@ -51,6 +73,18 @@ function createFluent }, ->(key: TKey, schemas: TSchemas): DiscriminatedUnionFluent> { - return createFluent(key, schemas as TSchemas & SchemaMember[], {} as Record); +>( + key: TKey, + schemas: TSchemas +): DiscriminatedUnionFluent & SchemaMember[], Record> { + const normalizedSchemas = (schemas as unknown as Record[]).map((member) => ({ + ...member, + [key]: normalizeDiscriminatorEntry(member[key] as string | number | boolean | DynamicOptionValue), + })); + + return createFluent( + key, + normalizedSchemas as NormalizeMembers & SchemaMember[], + {} as Record + ); } diff --git a/packages/dynz/src/schemas/discriminated-union/index.ts b/packages/dynz/src/schemas/discriminated-union/index.ts index a97283a..1f658df 100644 --- a/packages/dynz/src/schemas/discriminated-union/index.ts +++ b/packages/dynz/src/schemas/discriminated-union/index.ts @@ -1,3 +1,3 @@ +export * from "./discriminator"; export * from "./fluent"; -export * from "./resolve-member"; export * from "./types"; diff --git a/packages/dynz/src/schemas/discriminated-union/resolve-member.test.ts b/packages/dynz/src/schemas/discriminated-union/resolve-member.test.ts deleted file mode 100644 index af028c4..0000000 --- a/packages/dynz/src/schemas/discriminated-union/resolve-member.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { eq, v } from "../../functions"; -import { ref } from "../../reference"; -import type { Schema } from "../../types"; -import { object, string } from ".."; -import { getDiscriminatorLiteral, isDiscriminatorEnabled, isDynamicDiscriminatorValue } from "./resolve-member"; - -const rootSchema = object({ - country: string(), - kind: string(), -}); - -describe("isDynamicDiscriminatorValue", () => { - it("returns false for plain primitives", () => { - expect(isDynamicDiscriminatorValue("a")).toBe(false); - expect(isDynamicDiscriminatorValue(1)).toBe(false); - expect(isDynamicDiscriminatorValue(true)).toBe(false); - }); - - it("returns false for a Schema (it has a type field)", () => { - expect(isDynamicDiscriminatorValue(string() as Schema)).toBe(false); - }); - - it("returns true for a DynamicOptionValue", () => { - expect(isDynamicDiscriminatorValue({ enabled: true, value: "a" })).toBe(true); - expect(isDynamicDiscriminatorValue({ enabled: eq(ref("country"), v("NL")), value: "a" })).toBe(true); - }); -}); - -describe("getDiscriminatorLiteral", () => { - it("returns plain primitives as-is", () => { - expect(getDiscriminatorLiteral("a")).toBe("a"); - expect(getDiscriminatorLiteral(1)).toBe(1); - expect(getDiscriminatorLiteral(false)).toBe(false); - }); - - it("unwraps a DynamicOptionValue to its literal value", () => { - expect(getDiscriminatorLiteral({ enabled: true, value: "a" })).toBe("a"); - expect(getDiscriminatorLiteral({ enabled: false, value: 2 })).toBe(2); - }); -}); - -describe("isDiscriminatorEnabled", () => { - it("is always enabled for plain primitives", () => { - expect(isDiscriminatorEnabled("a", "$", { schema: rootSchema, values: {} })).toBe(true); - }); - - it("resolves a static boolean enabled flag", () => { - expect(isDiscriminatorEnabled({ enabled: true, value: "a" }, "$", { schema: rootSchema, values: {} })).toBe(true); - expect(isDiscriminatorEnabled({ enabled: false, value: "a" }, "$", { schema: rootSchema, values: {} })).toBe(false); - }); - - it("resolves a predicate-based enabled flag against the given values", () => { - const entry = { enabled: eq(ref("country"), v("NL")), value: "a" }; - - expect(isDiscriminatorEnabled(entry, "$", { schema: rootSchema, values: { country: "NL" } })).toBe(true); - expect(isDiscriminatorEnabled(entry, "$", { schema: rootSchema, values: { country: "BE" } })).toBe(false); - }); -}); diff --git a/packages/dynz/src/schemas/discriminated-union/resolve-member.ts b/packages/dynz/src/schemas/discriminated-union/resolve-member.ts deleted file mode 100644 index 58aa50a..0000000 --- a/packages/dynz/src/schemas/discriminated-union/resolve-member.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { resolvePredicate } from "../../functions"; -import type { ResolveContext, Schema } from "../../types"; -import { isBoolean, isNumber, isString } from "../../validate/validate-type"; -import type { DynamicOptionValue } from "../options/types"; - -/** - * Whether a discriminated union member's raw entry is a `DynamicOptionValue` - * (`{ value, enabled }`) rather than a plain primitive or a nested `Schema`. - * `Schema` objects always carry a `type` discriminant, which `DynamicOptionValue` - * never does. - */ -export function isDynamicDiscriminatorValue( - value: Schema | string | number | boolean | DynamicOptionValue -): value is DynamicOptionValue { - return typeof value === "object" && value !== null && !("type" in value); -} - -/** - * Unwraps a discriminator entry to its literal value, regardless of whether - * it's a plain primitive or a `DynamicOptionValue`. Only ever called on the - * discriminator key's entry, which is never actually a `Schema` at runtime - * (accepting the full `DiscriminatorEntry` type just avoids casts at call - * sites, since member entries are generically typed as `Schema | ...`). - */ -export function getDiscriminatorLiteral( - value: Schema | string | number | boolean | DynamicOptionValue -): string | number | boolean | undefined { - if (isDynamicDiscriminatorValue(value)) { - return value.value; - } - - if (isString(value) || isNumber(value) || isBoolean(value)) { - return value; - } - - return undefined; -} - -/** - * Whether a discriminator entry is currently enabled. Plain primitives are - * always enabled; `DynamicOptionValue` entries resolve their `enabled` flag - * (boolean or Predicate) the same way `options()` does (see `isOption` in - * `validate/validate-type.ts`). - */ -export function isDiscriminatorEnabled( - value: Schema | string | number | boolean | DynamicOptionValue, - path: string, - context: ResolveContext -): boolean { - if (!isDynamicDiscriminatorValue(value)) { - return true; - } - - if (typeof value.enabled === "boolean") { - return value.enabled; - } - - const resolved = resolvePredicate(value.enabled, path, context); - - return resolved === undefined ? false : resolved; -} diff --git a/packages/dynz/src/schemas/discriminated-union/types.ts b/packages/dynz/src/schemas/discriminated-union/types.ts index a41a366..37f64c1 100644 --- a/packages/dynz/src/schemas/discriminated-union/types.ts +++ b/packages/dynz/src/schemas/discriminated-union/types.ts @@ -1,6 +1,49 @@ +import type { Predicate } from "../../functions"; import type { BaseSchema, Schema, SchemaType } from "../../types"; import type { DynamicOptionValue } from "../options/types"; +/** + * The runtime type discriminant for {@link Discriminator}. Deliberately not a + * `SchemaType` member and `Discriminator` is deliberately not part of the `Schema` + * union — a discriminator value is never a valid standalone field, array item, or object + * property, only ever a discriminated union member's discriminator-key entry. Keeping it out + * of `Schema` means none of the generic schema-tree-walkers (`validateType`, a JSON Schema + * converter, etc.) need a case for a variant that's structurally impossible everywhere else. + */ +export const DISCRIMINATOR_TYPE = "discriminator" as const; + +/** + * The stored (normalized) shape of a discriminated union member's discriminator-key entry. + * Unlike every other schema kind this carries no `required`/`mutable`/`rules`/`default` — + * a discriminator's value is definitionally always present (if it's missing there's no + * matching member) and there's nothing to further validate beyond equality. + * + * Authors never construct this directly: `discriminatedUnion()` normalizes a plain + * `string | number | boolean | DynamicOptionValue` (exactly what an `options()` value looks + * like) into this shape at construction time. + */ +export type Discriminator = { + type: typeof DISCRIMINATOR_TYPE; + value: T; + /** Mirrors `DynamicOptionValue["enabled"]`; omitted/`undefined` means always enabled. */ + enabled?: boolean | Predicate; +}; + +/** Unwraps the authoring-time discriminator value to the literal type it normalizes to. */ +type ToDiscriminatorValue = T extends DynamicOptionValue + ? T["value"] + : T extends string | number | boolean + ? T + : never; + +/** + * Maps an authoring-time member (raw literal/DynamicOptionValue at the discriminator key) to + * its normalized, stored form (a Discriminator at the discriminator key). + */ +export type NormalizeMember = { + [K in keyof T]: K extends TKey ? Discriminator> : T[K]; +}; + // Per-element validation type: discriminator key accepts primitives or a DynamicOptionValue // (to conditionally enable/disable that member, exactly like an options() value), all other // keys must be Schema. Used as a self-referential constraint in discriminatedUnion() so @@ -12,13 +55,8 @@ export type CheckMember = { export type DiscriminatedUnionSchema< TKey extends string = string, - TSchemas extends Record[] = Record< - string, - Schema | string | number | boolean | DynamicOptionValue - >[], + TSchemas extends Record[] = Record[], > = BaseSchema & { key: TKey; - schemas: [TSchemas] extends [never] - ? Record[] - : TSchemas; + schemas: [TSchemas] extends [never] ? Record[] : TSchemas; }; diff --git a/packages/dynz/src/types/schema.ts b/packages/dynz/src/types/schema.ts index 9fdb3bf..74c359c 100644 --- a/packages/dynz/src/types/schema.ts +++ b/packages/dynz/src/types/schema.ts @@ -5,6 +5,7 @@ import type { BooleanSchema, DateSchema, DiscriminatedUnionSchema, + Discriminator, DynamicOptionValue, EnumValue, EnumValues, @@ -143,11 +144,13 @@ export type ObjectValue> = OptionalFields & Req type DiscriminatedMemberValue< TKey extends string, - TMember extends Record, -> = TMember extends Record + TMember extends Record, +> = TMember extends Record ? { [K in keyof TMember as K extends TKey ? K : TMember[K] extends Schema ? K : never]: K extends TKey - ? UnwrapOptionValue + ? TMember[K] extends Discriminator + ? V + : never : TMember[K] extends Schema ? SchemaValuesInternal : never; diff --git a/packages/dynz/src/utils/find-schema-by-path.test.ts b/packages/dynz/src/utils/find-schema-by-path.test.ts index 7e4cded..eb2d2ad 100644 --- a/packages/dynz/src/utils/find-schema-by-path.test.ts +++ b/packages/dynz/src/utils/find-schema-by-path.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; +import { DISCRIMINATOR_TYPE } from "../schemas"; import { SchemaType } from "../types"; -import { findSchemaByPath } from "./find-schema-by-path"; +import { DISCRIMINATOR_KEY_TYPE, findSchemaByPath } from "./find-schema-by-path"; describe("findSchemaByPath", () => { describe("object schema traversal", () => { @@ -239,36 +240,73 @@ describe("findSchemaByPath", () => { }); describe("discriminated union traversal", () => { - it("should return the union schema itself for a plain-primitive discriminator key path", () => { - const unionSchema = { - type: SchemaType.DISCRIMINATED_UNION, - key: "kind", - schemas: [ - { kind: "a", value: { type: SchemaType.STRING } }, - { kind: "b", value: { type: SchemaType.NUMBER } }, - ], + it("should return a DiscriminatorKey collecting every member's discriminator for the key path", () => { + const schema = { + type: SchemaType.OBJECT, + fields: { + union: { + type: SchemaType.DISCRIMINATED_UNION, + key: "kind", + schemas: [ + { kind: { type: DISCRIMINATOR_TYPE, value: "a" }, value: { type: SchemaType.STRING } }, + { kind: { type: DISCRIMINATOR_TYPE, value: "b" }, value: { type: SchemaType.NUMBER } }, + ], + }, + }, }; - const schema = { type: SchemaType.OBJECT, fields: { union: unionSchema } }; const result = findSchemaByPath("$.union.kind", schema); - expect(result).toEqual(unionSchema); - }); - - it("should return the union schema itself for a DynamicOptionValue discriminator key path", () => { - const unionSchema = { - type: SchemaType.DISCRIMINATED_UNION, + expect(result).toEqual({ + type: DISCRIMINATOR_KEY_TYPE, key: "kind", - schemas: [ - { kind: { enabled: true, value: "a" }, value: { type: SchemaType.STRING } }, - { kind: "b", value: { type: SchemaType.NUMBER } }, + discriminators: [ + { type: DISCRIMINATOR_TYPE, value: "a" }, + { type: DISCRIMINATOR_TYPE, value: "b" }, ], + included: undefined, + required: undefined, + mutable: undefined, + }); + }); + + it("carries over the union's own included/required/mutable onto the DiscriminatorKey", () => { + const schema = { + type: SchemaType.OBJECT, + fields: { + union: { + type: SchemaType.DISCRIMINATED_UNION, + key: "kind", + included: true, + required: false, + mutable: true, + schemas: [ + { kind: { type: DISCRIMINATOR_TYPE, value: "a", enabled: true }, value: { type: SchemaType.STRING } }, + ], + }, + }, }; - const schema = { type: SchemaType.OBJECT, fields: { union: unionSchema } }; const result = findSchemaByPath("$.union.kind", schema); - expect(result).toEqual(unionSchema); + expect(result).toMatchObject({ included: true, required: false, mutable: true }); + }); + + it("throws when explicitly asked for a discriminated_union but the path resolves to the discriminator key", () => { + const schema = { + type: SchemaType.OBJECT, + fields: { + union: { + type: SchemaType.DISCRIMINATED_UNION, + key: "kind", + schemas: [{ kind: { type: DISCRIMINATOR_TYPE, value: "a" }, value: { type: SchemaType.STRING } }], + }, + }, + }; + + expect(() => findSchemaByPath("$.union.kind", schema, SchemaType.DISCRIMINATED_UNION)).toThrow( + "Expected schema of type discriminated_union at path $.union.kind, but got discriminator_key" + ); }); it("should find a member's field schema", () => { @@ -278,7 +316,9 @@ describe("findSchemaByPath", () => { union: { type: SchemaType.DISCRIMINATED_UNION, key: "kind", - schemas: [{ kind: { enabled: true, value: "a" }, value: { type: SchemaType.STRING } }], + schemas: [ + { kind: { type: DISCRIMINATOR_TYPE, value: "a", enabled: true }, value: { type: SchemaType.STRING } }, + ], }, }, }; diff --git a/packages/dynz/src/utils/find-schema-by-path.ts b/packages/dynz/src/utils/find-schema-by-path.ts index 9f75598..89a00cf 100644 --- a/packages/dynz/src/utils/find-schema-by-path.ts +++ b/packages/dynz/src/utils/find-schema-by-path.ts @@ -1,15 +1,45 @@ -import { isDynamicDiscriminatorValue } from "../schemas"; +import type { Predicate } from "../functions"; +import { type Discriminator, isDiscriminator } from "../schemas"; import { type Schema, SchemaType } from "../types"; import { isNumber } from "../validate/validate-type"; -export function findSchemaByPath(path: string, schema: Schema): Schema; +export const DISCRIMINATOR_KEY_TYPE = "discriminator_key" as const; + +/** + * What `findSchemaByPath` returns when asked for a discriminated union's own discriminator-key + * path (e.g. "$.union.kind") rather than a member field. There's no single canonical schema for + * that position — each member has its own (possibly conditionally-enabled) discriminant — so + * this represents the set of all of them, carrying the parent union's own conditional + * properties (a `ref()` to this path is really referencing the union's own inclusion state). + */ +export type DiscriminatorKey = { + type: typeof DISCRIMINATOR_KEY_TYPE; + key: TKey; + /** Every member's discriminator entry, in declaration order. */ + discriminators: Discriminator[]; + included?: boolean | Predicate | undefined; + required?: boolean | Predicate | undefined; + mutable?: boolean | Predicate | undefined; +}; + +export function isDiscriminatorKey(schema: Schema | DiscriminatorKey): schema is DiscriminatorKey { + return schema.type === DISCRIMINATOR_KEY_TYPE; +} + +export type ReturnSchema = Schema | DiscriminatorKey; + +export function findSchemaByPath(path: string, schema: Schema): ReturnSchema; export function findSchemaByPath(path: string, schema: Schema, type: T["type"]): T; -export function findSchemaByPath(path: string, schema: Schema, type?: T["type"]): Schema { +export function findSchemaByPath( + path: string, + schema: Schema, + type?: T["type"] +): ReturnSchema { const nestedSchema = path .split(/[.[\]]/) .filter(Boolean) .splice(1) - .reduce((prev, cur) => { + .reduce((prev, cur) => { if (prev.type === SchemaType.ARRAY) { if (!isNumber(+cur)) { throw new Error(`Expected an array index at path ${path}, but got ${cur}`); @@ -29,22 +59,27 @@ export function findSchemaByPath(path: string, schema } if (prev.type === SchemaType.DISCRIMINATED_UNION) { + // The discriminator key itself has no single canonical schema across members (each + // member's discriminator value may differ) — return the set of possible discriminators, + // carrying the union's own conditional properties. + if (cur === prev.key) { + return { + type: DISCRIMINATOR_KEY_TYPE, + key: prev.key, + discriminators: prev.schemas.map((member) => member[prev.key] as Discriminator), + included: prev.included, + required: prev.required, + mutable: prev.mutable, + }; + } + for (const member of prev.schemas) { const childSchema = member[cur]; if (childSchema !== undefined) { - /** - * When the childSchema is a primitive type then we need - * to return the union schema since the union type doesnt - * have an actual schema attached to it. - */ - if ( - typeof childSchema === "boolean" || - typeof childSchema === "number" || - typeof childSchema === "string" || - isDynamicDiscriminatorValue(childSchema) - ) { - return prev; + if (isDiscriminator(childSchema)) { + // Structurally unreachable: cur !== prev.key was already handled above. + throw new Error(`Unexpected discriminator schema at path ${path}`); } return childSchema; diff --git a/packages/dynz/src/utils/get-nested.test.ts b/packages/dynz/src/utils/get-nested.test.ts index 0fcc1c8..0093751 100644 --- a/packages/dynz/src/utils/get-nested.test.ts +++ b/packages/dynz/src/utils/get-nested.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { DISCRIMINATOR_TYPE } from "../schemas"; import { SchemaType } from "../types"; import { getNested } from "./get-nested"; @@ -314,13 +315,13 @@ describe("getNested", () => { describe("discriminated union traversal", () => { const memberValueSchema = { type: SchemaType.STRING }; - it("should traverse into the matching member for a plain-primitive discriminator", () => { + it("should traverse into the matching member for a discriminator with no enabled flag", () => { const schema = { type: SchemaType.DISCRIMINATED_UNION, key: "kind", schemas: [ - { kind: "a", value: memberValueSchema }, - { kind: "b", value: { type: SchemaType.NUMBER } }, + { kind: { type: DISCRIMINATOR_TYPE, value: "a" }, value: memberValueSchema }, + { kind: { type: DISCRIMINATOR_TYPE, value: "b" }, value: { type: SchemaType.NUMBER } }, ], }; const value = { kind: "a", value: "hello" }; @@ -330,13 +331,13 @@ describe("getNested", () => { expect(result).toEqual({ schema: memberValueSchema, value: "hello" }); }); - it("should traverse into the matching member for a DynamicOptionValue discriminator", () => { + it("should traverse into the matching member for a statically enabled discriminator", () => { const schema = { type: SchemaType.DISCRIMINATED_UNION, key: "kind", schemas: [ - { kind: { enabled: true, value: "a" }, value: memberValueSchema }, - { kind: "b", value: { type: SchemaType.NUMBER } }, + { kind: { type: DISCRIMINATOR_TYPE, value: "a", enabled: true }, value: memberValueSchema }, + { kind: { type: DISCRIMINATOR_TYPE, value: "b" }, value: { type: SchemaType.NUMBER } }, ], }; const value = { kind: "a", value: "hello" }; @@ -350,7 +351,7 @@ describe("getNested", () => { const schema = { type: SchemaType.DISCRIMINATED_UNION, key: "kind", - schemas: [{ kind: { enabled: true, value: "a" }, value: memberValueSchema }], + schemas: [{ kind: { type: DISCRIMINATOR_TYPE, value: "a", enabled: true }, value: memberValueSchema }], }; const value = { kind: "unknown", value: "hello" }; diff --git a/packages/dynz/src/utils/get-nested.ts b/packages/dynz/src/utils/get-nested.ts index f533c13..10bcad9 100644 --- a/packages/dynz/src/utils/get-nested.ts +++ b/packages/dynz/src/utils/get-nested.ts @@ -1,18 +1,19 @@ -import { getDiscriminatorLiteral, isDynamicDiscriminatorValue } from "../schemas"; +import { type Discriminator, isDiscriminator } from "../schemas"; import { type Schema, SchemaType } from "../types"; import { isObject } from "../validate/validate-type"; import { coerceSchema } from "./coerce"; +import { DISCRIMINATOR_KEY_TYPE, type DiscriminatorKey, isDiscriminatorKey } from "./find-schema-by-path"; export function getNested( path: string, schema: T, value: unknown -): { schema: Schema; value: unknown } | null { +): { schema: Schema | DiscriminatorKey; value: unknown } | null { const result = path .split(/[.[\]]/) .filter(Boolean) .splice(1) - .reduce<{ schema: Schema; value: unknown } | null>( + .reduce<{ schema: Schema | DiscriminatorKey; value: unknown } | null>( (acc, cur) => { if (acc === null) { return null; @@ -51,25 +52,43 @@ export function getNested( } if (acc.schema.type === SchemaType.DISCRIMINATED_UNION) { - // if the key is referenced return the schema of the union type - if (cur === acc.schema.key) { + // The discriminator key itself has no single canonical schema across members — return + // a DiscriminatorKey (same shape findSchemaByPath produces), paired with the raw + // discriminator value actually present. + const { schema, value } = acc; + + if (cur === schema.key) { return { - value: isObject(acc.value) ? acc.value[acc.schema.key] : undefined, - schema: acc.schema, + value: isObject(acc.value) ? acc.value[schema.key] : undefined, + schema: { + type: DISCRIMINATOR_KEY_TYPE, + key: schema.key, + discriminators: schema.schemas.map((member) => member[schema.key] as Discriminator), + included: schema.included, + required: schema.required, + mutable: schema.mutable, + }, }; } - if (acc.value === undefined || acc.value === null) { + /** + * If value is undefined or null we cannot determine + * which schema to use for the given union; returning null in that case + */ + if (value === undefined || value === null) { return null; } - if (!isObject(acc.value)) { - throw new Error(`Expected an object at path ${path}, but got ${typeof acc.value}`); + if (!isObject(value)) { + throw new Error(`Expected an object at path ${path}, but got ${typeof value}`); } - const { key } = acc.schema; - const discriminatorValue = acc.value[key]; - const matchingMember = acc.schema.schemas.find((s) => getDiscriminatorLiteral(s[key]) === discriminatorValue); + const { key } = schema; + const discriminatorValue = value[key]; + const matchingMember = schema.schemas.find((s) => { + const discriminator = s[key]; + return isDiscriminator(discriminator) && discriminator.value === discriminatorValue; + }); if (matchingMember === undefined) { return null; @@ -81,20 +100,14 @@ export function getNested( return null; } - if ( - typeof childSchema === "string" || - typeof childSchema === "number" || - typeof childSchema === "boolean" || - isDynamicDiscriminatorValue(childSchema) - ) { - return { - value: isObject(acc.value) ? acc.value[acc.schema.key] : undefined, - schema: acc.schema, - }; + if (isDiscriminator(childSchema)) { + // Structurally unreachable: cur !== key was already handled above, and only the + // discriminator key's entry is ever a Discriminator. + throw new Error(`Unexpected discriminator schema at path ${path}`); } return { - value: acc.value[cur], + value: value[cur], schema: childSchema, }; } @@ -104,10 +117,11 @@ export function getNested( { value, schema } ); - return result === null - ? null - : { - schema: result.schema, - value: coerceSchema(result.schema, result.value), - }; + if (result === null) { + return null; + } + + return isDiscriminatorKey(result.schema) + ? { schema: result.schema, value: result.value } + : { schema: result.schema, value: coerceSchema(result.schema, result.value) }; } diff --git a/packages/dynz/src/validate/validate.ts b/packages/dynz/src/validate/validate.ts index f2dc872..92cd387 100644 --- a/packages/dynz/src/validate/validate.ts +++ b/packages/dynz/src/validate/validate.ts @@ -2,7 +2,7 @@ import { _resolveProperty, resolveProperty, resolveRules } from "../conditions"; import { resolve } from "../functions"; import { isPivateValue, isValueMasked, type PrivateValue } from "../private"; import { validateRule } from "../rules"; -import { getDiscriminatorLiteral, isDiscriminatorEnabled, isDynamicDiscriminatorValue } from "../schemas"; +import { isDiscriminatorEnabled, isDiscriminator } from "../schemas"; import { type Context, ErrorCode, @@ -341,11 +341,14 @@ export async function _validate( } const discriminatorValue = newValue[schema.key]; - const matchingMember = schema.schemas.find( - (s) => - getDiscriminatorLiteral(s[schema.key]) === discriminatorValue && - isDiscriminatorEnabled(s[schema.key], path, context) - ); + const matchingMember = schema.schemas.find((s) => { + const discriminator = s[schema.key]; + return ( + isDiscriminator(discriminator) && + discriminator.value === discriminatorValue && + isDiscriminatorEnabled(discriminator, path, context) + ); + }); if (matchingMember === undefined) { return { @@ -367,17 +370,12 @@ export async function _validate( const entries = await Promise.all( Object.entries(matchingMember).map(async ([key, innerSchema]) => { - if ( - typeof innerSchema === "string" || - typeof innerSchema === "boolean" || - typeof innerSchema === "number" || - isDynamicDiscriminatorValue(innerSchema) - ) { + if (isDiscriminator(innerSchema)) { return { key, result: { success: true, - values: isDynamicDiscriminatorValue(innerSchema) ? innerSchema.value : innerSchema, + values: innerSchema.value, } as ValidationSuccesResult, }; } diff --git a/packages/react-hook-form/src/hooks/use-conditional-property.ts b/packages/react-hook-form/src/hooks/use-conditional-property.ts index 7c9af6f..df1c408 100644 --- a/packages/react-hook-form/src/hooks/use-conditional-property.ts +++ b/packages/react-hook-form/src/hooks/use-conditional-property.ts @@ -1,4 +1,4 @@ -import { findSchemaByPath, getConditionDependencies, resolveProperty, SchemaType } from "dynz"; +import { findSchemaByPath, getConditionDependencies, isDiscriminatorKey, resolveProperty, SchemaType } from "dynz"; import { useMemo } from "react"; import { useWatch } from "react-hook-form"; import { useDynzFormContext } from "./use-dynz-form-context"; @@ -11,21 +11,18 @@ function getPropertyDependencies( schema: Parameters[1] ): string[] { const segments = path.split(/[.[\]]/).filter(Boolean); - let previousAncestor: ReturnType | undefined; return segments.flatMap((_, i) => { const ancestorPath = segments.slice(0, i + 1).join("."); const ancestor = findSchemaByPath(ancestorPath, schema); - // A discriminated union's key-path alias (e.g. "$.left_side.type") resolves to the - // SAME schema object as its own path ("$.left_side"), since the key isn't a real - // nested field. Skip reprocessing it here: its included/required/mutable predicate - // was already handled at its true (shallower) path, and reprocessing it here would - // resolve any relative refs it contains against the wrong (deeper) base path. - if (ancestor === previousAncestor) { + // A discriminated union's discriminator-key path (e.g. "$.left_side.type") isn't a real + // nested field — its included/required/mutable predicate was already handled at the + // union's own (shallower) ancestor path, so skip reprocessing it here. Reprocessing it + // would also resolve any relative refs it contains against the wrong (deeper) base path. + if (isDiscriminatorKey(ancestor)) { return []; } - previousAncestor = ancestor; // When ancestor is a discriminuted union the 'key' of the union // must always be added as an implicit dependency diff --git a/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts b/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts index e3f440a..a6c03d8 100644 --- a/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts +++ b/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts @@ -2,9 +2,8 @@ import { type DiscriminatedUnionSchema, findSchemaByPath, getConditionDependencies, - getDiscriminatorLiteral, - isDynamicDiscriminatorValue, - resolvePredicate, + isDiscriminator, + isDiscriminatorEnabled, SchemaType, } from "dynz"; import { useWatch } from "react-hook-form"; @@ -18,10 +17,17 @@ export function useDiscriminatedUnionKeyValues(name: string) { // TODO: memoize const dependencies = unionSchema.schemas.reduce((acc, member) => { - const raw = member[unionSchema.key]; - if (isDynamicDiscriminatorValue(raw) && typeof raw.enabled !== "boolean") { - acc.push(...getConditionDependencies(raw.enabled, "$", schema)); + const discriminator = member[unionSchema.key]; + + if ( + !isDiscriminator(discriminator) || + discriminator.enabled === undefined || + typeof discriminator.enabled === "boolean" + ) { + return acc; } + + acc.push(...getConditionDependencies(discriminator.enabled, "$", schema)); return acc; }, []); @@ -34,21 +40,15 @@ export function useDiscriminatedUnionKeyValues(name: string) { const values = getValues(); return unionSchema.schemas.map((member) => { - const raw = member[unionSchema.key]; + const discriminator = member[unionSchema.key]; - if (!isDynamicDiscriminatorValue(raw)) { - return { - enabled: true, - value: raw as string | number | boolean, - }; + if (!isDiscriminator(discriminator)) { + throw new Error(`No discriminator schema found for member of union "${name}"`); } - const enabled = - typeof raw.enabled === "boolean" ? raw.enabled : resolvePredicate(raw.enabled, "$", { schema, values }); - return { - enabled: Boolean(enabled), - value: getDiscriminatorLiteral(raw), + enabled: isDiscriminatorEnabled(discriminator, "$", { schema, values }), + value: discriminator.value, }; }); } From 1efba3695ce0382c77bed7e0154dac768c94b05d Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Thu, 9 Jul 2026 23:18:50 +0200 Subject: [PATCH 4/4] fixed small issues --- .../src/utils/find-schema-by-path.test.ts | 1 + .../dynz/src/utils/find-schema-by-path.ts | 21 ++++++++++++++----- packages/dynz/src/utils/get-nested.ts | 1 + .../use-discriminated-union-key-values.ts | 9 +++++++- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/dynz/src/utils/find-schema-by-path.test.ts b/packages/dynz/src/utils/find-schema-by-path.test.ts index eb2d2ad..2560abb 100644 --- a/packages/dynz/src/utils/find-schema-by-path.test.ts +++ b/packages/dynz/src/utils/find-schema-by-path.test.ts @@ -260,6 +260,7 @@ describe("findSchemaByPath", () => { expect(result).toEqual({ type: DISCRIMINATOR_KEY_TYPE, key: "kind", + schemas: schema.fields.union.schemas, discriminators: [ { type: DISCRIMINATOR_TYPE, value: "a" }, { type: DISCRIMINATOR_TYPE, value: "b" }, diff --git a/packages/dynz/src/utils/find-schema-by-path.ts b/packages/dynz/src/utils/find-schema-by-path.ts index 89a00cf..d66877b 100644 --- a/packages/dynz/src/utils/find-schema-by-path.ts +++ b/packages/dynz/src/utils/find-schema-by-path.ts @@ -16,6 +16,7 @@ export type DiscriminatorKey = { type: typeof DISCRIMINATOR_KEY_TYPE; key: TKey; /** Every member's discriminator entry, in declaration order. */ + schemas: Record[]; discriminators: Discriminator[]; included?: boolean | Predicate | undefined; required?: boolean | Predicate | undefined; @@ -29,11 +30,20 @@ export function isDiscriminatorKey(schema: Schema | DiscriminatorKey): schema is export type ReturnSchema = Schema | DiscriminatorKey; export function findSchemaByPath(path: string, schema: Schema): ReturnSchema; -export function findSchemaByPath(path: string, schema: Schema, type: T["type"]): T; -export function findSchemaByPath( +export function findSchemaByPath( path: string, schema: Schema, - type?: T["type"] + type: T["type"] +): T; +export function findSchemaByPath( + path: string, + schema: Schema, + ...type: Array +): T; +export function findSchemaByPath( + path: string, + schema: Schema, + ...types: Array ): ReturnSchema { const nestedSchema = path .split(/[.[\]]/) @@ -66,6 +76,7 @@ export function findSchemaByPath( return { type: DISCRIMINATOR_KEY_TYPE, key: prev.key, + schemas: prev.schemas, discriminators: prev.schemas.map((member) => member[prev.key] as Discriminator), included: prev.included, required: prev.required, @@ -91,8 +102,8 @@ export function findSchemaByPath( throw new Error(`Cannot find schema at path ${path}`); }, schema); - if (type !== undefined && nestedSchema.type !== type) { - throw new Error(`Expected schema of type ${type} at path ${path}, but got ${nestedSchema.type}`); + if (types.length > 0 && !types.some((t) => t === nestedSchema.type)) { + throw new Error(`Expected schema of type ${types.join(",")} at path ${path}, but got ${nestedSchema.type}`); } return nestedSchema; diff --git a/packages/dynz/src/utils/get-nested.ts b/packages/dynz/src/utils/get-nested.ts index 10bcad9..824729e 100644 --- a/packages/dynz/src/utils/get-nested.ts +++ b/packages/dynz/src/utils/get-nested.ts @@ -63,6 +63,7 @@ export function getNested( schema: { type: DISCRIMINATOR_KEY_TYPE, key: schema.key, + schemas: schema.schemas, discriminators: schema.schemas.map((member) => member[schema.key] as Discriminator), included: schema.included, required: schema.required, diff --git a/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts b/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts index a6c03d8..64750de 100644 --- a/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts +++ b/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts @@ -1,5 +1,7 @@ import { + DISCRIMINATOR_KEY_TYPE, type DiscriminatedUnionSchema, + type DiscriminatorKey, findSchemaByPath, getConditionDependencies, isDiscriminator, @@ -13,7 +15,12 @@ export function useDiscriminatedUnionKeyValues(name: string) { const { control, getValues, schema } = useDynzFormContext(); // TODO: memoize - const unionSchema = findSchemaByPath(`$.${name}`, schema, SchemaType.DISCRIMINATED_UNION); + const unionSchema = findSchemaByPath( + `$.${name}`, + schema, + SchemaType.DISCRIMINATED_UNION, + DISCRIMINATOR_KEY_TYPE + ); // TODO: memoize const dependencies = unionSchema.schemas.reduce((acc, member) => {