&
+ TProps & {
+ setRequired: (
+ value: P
+ ) => DiscriminatedUnionFluent;
+ optional: () => DiscriminatedUnionFluent;
+ setMutable: (
+ value: P
+ ) => DiscriminatedUnionFluent;
+ setIncluded: (
+ value: P
+ ) => DiscriminatedUnionFluent;
+ setPrivate: (value: P) => DiscriminatedUnionFluent;
+ setUi: (config: TUI) => DiscriminatedUnionFluent;
+ };
+
+function createFluent(
+ key: TKey,
+ schemas: TMembers,
+ props: TProps
+): DiscriminatedUnionFluent {
+ const setProp = (k: K, v: V): DiscriminatedUnionFluent> =>
+ createFluent(key, schemas, { ...props, [k]: v } as TProps & Record);
+
+ return {
+ type: SchemaType.DISCRIMINATED_UNION,
+ key,
+ schemas,
+ ...props,
+ setRequired: (v: P) => setProp("required", v),
+ optional: () => setProp("required", false as false),
+ setMutable:
(v: P) => setProp("mutable", v),
+ setIncluded:
(v: P) => setProp("included", v),
+ setPrivate:
(v: P) => setProp("private", v),
+ setUi: (config: TUI) => setProp("ui", config),
+ } as DiscriminatedUnionFluent;
+}
+
+export function discriminatedUnion<
+ const TKey extends string,
+ const TSchemas extends { [I in keyof TSchemas]: CheckMember },
+>(key: TKey, schemas: TSchemas): DiscriminatedUnionFluent> {
+ return createFluent(key, schemas as TSchemas & SchemaMember[], {} as Record);
+}
diff --git a/packages/dynz/src/schemas/discriminated-union/index.ts b/packages/dynz/src/schemas/discriminated-union/index.ts
new file mode 100644
index 0000000..12eb95b
--- /dev/null
+++ b/packages/dynz/src/schemas/discriminated-union/index.ts
@@ -0,0 +1,2 @@
+export * from "./fluent";
+export * from "./types";
diff --git a/packages/dynz/src/schemas/discriminated-union/types.ts b/packages/dynz/src/schemas/discriminated-union/types.ts
new file mode 100644
index 0000000..5e05ac8
--- /dev/null
+++ b/packages/dynz/src/schemas/discriminated-union/types.ts
@@ -0,0 +1,19 @@
+import type { BaseSchema, Schema, SchemaType } from "../../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.
+export type CheckMember = {
+ [K in keyof T]: K extends TKey ? string | number | boolean : T[K] extends Schema ? T[K] : never;
+};
+
+export type DiscriminatedUnionSchema<
+ TKey extends string = string,
+ TSchemas extends Record[] = Record<
+ string,
+ Schema | string | number | boolean
+ >[],
+> = BaseSchema & {
+ key: TKey;
+ schemas: [TSchemas] extends [never] ? Record[] : TSchemas;
+};
diff --git a/packages/dynz/src/schemas/index.ts b/packages/dynz/src/schemas/index.ts
index 84c46fe..a62da0a 100644
--- a/packages/dynz/src/schemas/index.ts
+++ b/packages/dynz/src/schemas/index.ts
@@ -9,4 +9,5 @@ export * from "./number";
export * from "./object";
export * from "./options";
export * from "./shared";
+export * from "./discriminated-union";
export * from "./string";
diff --git a/packages/dynz/src/types/schema.ts b/packages/dynz/src/types/schema.ts
index 21d38e5..5f7371f 100644
--- a/packages/dynz/src/types/schema.ts
+++ b/packages/dynz/src/types/schema.ts
@@ -4,6 +4,7 @@ import type {
ArraySchema,
BooleanSchema,
DateSchema,
+ DiscriminatedUnionSchema,
DynamicOptionValue,
EnumValue,
EnumValues,
@@ -13,6 +14,7 @@ import type {
OptionsSchema,
StringSchema,
} from "../schemas";
+
import type { EnumSchema } from "../schemas/enum";
import type { ExpressionSchema } from "../schemas/expression";
import type { LiteralSchema } from "../schemas/literal";
@@ -31,6 +33,7 @@ export const SchemaType = {
FILE: "file",
EXPRESSION: "expression",
LITERAL: "literal",
+ DISCRIMINATED_UNION: "discriminated_union",
} as const;
export type SchemaType = EnumValues;
@@ -61,7 +64,8 @@ export type Schema =
| DateSchema
| EnumSchema
| ExpressionSchema
- | LiteralSchema;
+ | LiteralSchema
+ | DiscriminatedUnionSchema;
export type IsIncluded = T extends { included: true }
? true
@@ -117,7 +121,9 @@ export type ValueType = T extends typeof Sche
? unknown
: T extends typeof SchemaType.LITERAL
? string | number | boolean | null
- : never;
+ : T extends typeof SchemaType.DISCRIMINATED_UNION
+ ? Record
+ : never;
export type ValueTypeOrUndefined = ValueType | undefined | Array;
@@ -135,6 +141,19 @@ type RequiredFields> = {
export type ObjectValue> = OptionalFields & RequiredFields;
+type DiscriminatedMemberValue<
+ TKey extends string,
+ 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]
+ : TMember[K] extends Schema
+ ? SchemaValuesInternal
+ : never;
+ }
+ : never;
+
export type SchemaValuesInternal = T extends ObjectSchema
? Prettify>
: T extends ArraySchema
@@ -143,8 +162,10 @@ export type SchemaValuesInternal = T extends ObjectSchema>
: T extends OptionsSchema
? MakeOptional>>
- : T extends LiteralSchema
- ? MakeOptional
- : MakeOptional>;
+ : T extends DiscriminatedUnionSchema
+ ? MakeOptional>
+ : T extends LiteralSchema
+ ? MakeOptional
+ : MakeOptional>;
export type SchemaValues = Prettify>>;
diff --git a/packages/dynz/src/utils/find-schema-by-path.ts b/packages/dynz/src/utils/find-schema-by-path.ts
index 7f20684..94f82db 100644
--- a/packages/dynz/src/utils/find-schema-by-path.ts
+++ b/packages/dynz/src/utils/find-schema-by-path.ts
@@ -27,6 +27,30 @@ export function findSchemaByPath(path: string, schema
return childSchema;
}
+ if (prev.type === SchemaType.DISCRIMINATED_UNION) {
+ 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"
+ ) {
+ return prev;
+ }
+
+ return childSchema;
+ }
+ }
+ throw new Error(`No schema found for path ${path}`);
+ }
+
throw new Error(`Cannot find schema at path ${path}`);
}, schema);
diff --git a/packages/dynz/src/utils/get-nested.ts b/packages/dynz/src/utils/get-nested.ts
index 141b92a..06d990c 100644
--- a/packages/dynz/src/utils/get-nested.ts
+++ b/packages/dynz/src/utils/get-nested.ts
@@ -6,13 +6,17 @@ export function getNested(
path: string,
schema: T,
value: unknown
-): { schema: Schema; value: unknown } {
+): { schema: Schema; value: unknown } | null {
const result = path
.split(/[.[\]]/)
.filter(Boolean)
.splice(1)
- .reduce<{ schema: Schema; value: unknown }>(
+ .reduce<{ schema: Schema; value: unknown } | null>(
(acc, cur) => {
+ if (acc === null) {
+ return null;
+ }
+
if (acc.schema.type === SchemaType.ARRAY) {
if (!Array.isArray(acc.value)) {
throw new Error(`Expected an array at path ${path}, but got ${typeof acc.value}`);
@@ -45,13 +49,59 @@ 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) {
+ return {
+ value: isObject(acc.value) ? acc.value[acc.schema.key] : undefined,
+ schema: acc.schema,
+ };
+ }
+
+ if (acc.value === undefined || acc.value === null) {
+ return null;
+ }
+
+ if (!isObject(acc.value)) {
+ throw new Error(`Expected an object at path ${path}, but got ${typeof acc.value}`);
+ }
+ const { key } = acc.schema;
+
+ const discriminatorValue = acc.value[key];
+ const matchingMember = acc.schema.schemas.find((s) => s[key] === discriminatorValue);
+
+ if (matchingMember === undefined) {
+ return null;
+ }
+
+ const childSchema = matchingMember[cur];
+
+ if (childSchema === undefined) {
+ return null;
+ }
+
+ if (typeof childSchema === "string" || typeof childSchema === "number" || typeof childSchema === "boolean") {
+ return {
+ value: isObject(acc.value) ? acc.value[acc.schema.key] : undefined,
+ schema: acc.schema,
+ };
+ }
+
+ return {
+ value: acc.value,
+ schema: childSchema,
+ };
+ }
+
throw new Error("Cannot get nested value on non array or non object");
},
{ value, schema }
);
- return {
- schema: result.schema,
- value: coerceSchema(result.schema, result.value),
- };
+ return result === null
+ ? null
+ : {
+ schema: result.schema,
+ value: coerceSchema(result.schema, result.value),
+ };
}
diff --git a/packages/dynz/src/validate/validate-type.ts b/packages/dynz/src/validate/validate-type.ts
index f3dc4b9..07ad195 100644
--- a/packages/dynz/src/validate/validate-type.ts
+++ b/packages/dynz/src/validate/validate-type.ts
@@ -38,6 +38,8 @@ export function validateType(
return true;
case SchemaType.LITERAL:
return value === schema.value;
+ case SchemaType.DISCRIMINATED_UNION:
+ return isObject(value);
}
}
@@ -74,6 +76,8 @@ export function validateShallowType(type: T, value: unknow
return true;
case SchemaType.LITERAL:
return isString(value) || isNumber(value) || isBoolean(value) || value === null;
+ case SchemaType.DISCRIMINATED_UNION:
+ return true;
}
}
diff --git a/packages/dynz/src/validate/validate.ts b/packages/dynz/src/validate/validate.ts
index 71c5f0c..f80b8b3 100644
--- a/packages/dynz/src/validate/validate.ts
+++ b/packages/dynz/src/validate/validate.ts
@@ -1,4 +1,4 @@
-import { resolveProperty, resolveRules } from "../conditions";
+import { _resolveProperty, resolveProperty, resolveRules } from "../conditions";
import { resolve } from "../functions";
import { isPivateValue, isValueMasked, type PrivateValue } from "../private";
import { validateRule } from "../rules";
@@ -11,6 +11,7 @@ import {
type ValidateOptions,
type ValidateRuleContextUnion,
type ValidationResult,
+ type ValidationSuccesResult,
} from "../types";
import { coerceSchema } from "../utils";
import { isArray, isObject, validateType } from "./validate-type";
@@ -326,40 +327,79 @@ export async function _validate(
return acc;
}
- // return newValue.reduce>(
- // (acc, cur, index) => {
- // const result = _validate(
- // schema.schema,
- // {
- // current: currentValue?.[index],
- // new: cur,
- // },
- // `${path}.[${index}]`,
- // newContext
- // );
-
- // if (acc.success) {
- // if (result.success) {
- // acc.values.push(result.values);
- // return acc;
- // }
-
- // return {
- // success: false,
- // errors: result.errors,
- // };
- // }
-
- // if (result.success) {
- // return acc;
- // }
-
- // acc.errors.push(...result.errors);
- // return acc;
- // },
- // { success: true, values: [] }
- // );
- // }
+ /**
+ * Validate discriminated union - find the matching member by the discriminator key value.
+ */
+ if (schema.type === SchemaType.DISCRIMINATED_UNION) {
+ if (!isObject(newValue)) {
+ throw new Error(`new value is not an object: ${newValue}`);
+ }
+
+ if (isDefined(currentValue) && !isObject(currentValue)) {
+ throw new Error(`current value is not an object: ${currentValue}`);
+ }
+
+ const discriminatorValue = newValue[schema.key];
+ const matchingMember = schema.schemas.find((s) => s[schema.key] === discriminatorValue);
+
+ if (matchingMember === undefined) {
+ return {
+ success: false,
+ errors: [
+ {
+ path,
+ schema,
+ value: newValue,
+ current: currentValue,
+ customCode: ErrorCode.TYPE,
+ code: ErrorCode.TYPE,
+ expectedType: schema.type,
+ message: `The value for schema ${path} does not match any member of the discriminated union (key: ${schema.key}=${String(discriminatorValue)})`,
+ },
+ ],
+ };
+ }
+
+ const entries = await Promise.all(
+ Object.entries(matchingMember).map(async ([key, innerSchema]) => {
+ if (typeof innerSchema === "string" || typeof innerSchema === "boolean" || typeof innerSchema === "number") {
+ return {
+ key,
+ result: {
+ success: true,
+ values: innerSchema,
+ } as ValidationSuccesResult,
+ };
+ }
+
+ return {
+ key,
+ result: await _validate(
+ innerSchema,
+ { current: currentValue?.[key], new: newValue[key] },
+ `${path}.${key}`,
+ context
+ ),
+ };
+ })
+ );
+
+ let acc = { success: true, values: {} } as ValidationResult>;
+
+ for (const { key, result } of entries) {
+ if (acc.success) {
+ if (result.success) {
+ acc.values[key] = result.values;
+ } else {
+ acc = { success: false, errors: result.errors };
+ }
+ } else if (!result.success) {
+ acc.errors.push(...result.errors);
+ }
+ }
+
+ return acc;
+ }
return {
success: true,
diff --git a/packages/react-hook-form/src/hooks/index.ts b/packages/react-hook-form/src/hooks/index.ts
index 1244194..15a2c45 100644
--- a/packages/react-hook-form/src/hooks/index.ts
+++ b/packages/react-hook-form/src/hooks/index.ts
@@ -1,3 +1,4 @@
+export * from "./use-discriminated-union-key-values";
export * from "./use-dynz-form";
export * from "./use-dynz-form-context";
export * from "./use-is-included";
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 72ecb0d..3eb49f7 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 } from "dynz";
+import { findSchemaByPath, getConditionDependencies, resolveProperty, SchemaType } from "dynz";
import { useMemo } from "react";
import { useWatch } from "react-hook-form";
import { useDynzFormContext } from "./use-dynz-form-context";
@@ -13,11 +13,23 @@ function getPropertyDependencies(
const segments = path.split(/[.[\]]/).filter(Boolean);
return segments.flatMap((_, i) => {
const ancestorPath = segments.slice(0, i + 1).join(".");
- const ancestorPropertyValue = findSchemaByPath(ancestorPath, schema)[property];
+ const ancestor = findSchemaByPath(ancestorPath, schema);
+
+ // When ancestor is a discriminuted union the 'key' of the union
+ // must always be added as an implicit dependency
+ const dependencies =
+ ancestor.type === SchemaType.DISCRIMINATED_UNION ? [`${ancestorPath}.${ancestor.key}`.slice(2)] : [];
+
+ const ancestorPropertyValue = ancestor[property];
+
if (ancestorPropertyValue === undefined || typeof ancestorPropertyValue === "boolean") {
- return [];
+ return dependencies;
}
- return getConditionDependencies(ancestorPropertyValue, ancestorPath, schema).map((f) => f.slice(2));
+
+ return [
+ ...dependencies,
+ ...getConditionDependencies(ancestorPropertyValue, ancestorPath, schema).map((f) => f.slice(2)),
+ ];
});
}
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
new file mode 100644
index 0000000..c599323
--- /dev/null
+++ b/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts
@@ -0,0 +1,18 @@
+import { type DiscriminatedUnionSchema, findSchemaByPath, SchemaType } from "dynz";
+import { useMemo } from "react";
+import { useDynzFormContext } from "./use-dynz-form-context";
+
+export function useDiscriminatedUnionKeyValues(name: string) {
+ const { schema } = useDynzFormContext();
+
+ const unionSchema = findSchemaByPath(`$.${name}`, schema, SchemaType.DISCRIMINATED_UNION);
+
+ return useMemo(() => {
+ const key = unionSchema.key;
+
+ return unionSchema.schemas.map((member) => ({
+ enabled: true,
+ value: member[key] as string | number | boolean,
+ }));
+ }, [unionSchema]);
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6fd3e51..ae00722 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -116,7 +116,7 @@ importers:
dependencies:
'@hookform/resolvers':
specifier: ^5.2.1
- version: 5.2.2(react-hook-form@7.63.0(react@19.2.0))
+ version: 5.2.2(react-hook-form@7.80.0(react@19.2.0))
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@types/react-dom@19.1.9(@types/react@19.1.15))(@types/react@19.1.15)(react-dom@19.1.1(react@19.2.0))(react@19.2.0)
@@ -142,8 +142,8 @@ importers:
specifier: ^4.3.4
version: 4.3.9(next@15.3.5(react-dom@19.1.1(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.2)
react-hook-form:
- specifier: ^7.62.0
- version: 7.63.0(react@19.2.0)
+ specifier: ^7.75.0
+ version: 7.80.0(react@19.2.0)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
@@ -3868,6 +3868,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
+ react-hook-form@7.80.0:
+ resolution: {integrity: sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18 || ^19
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -5117,6 +5123,11 @@ snapshots:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.63.0(react@19.2.0)
+ '@hookform/resolvers@5.2.2(react-hook-form@7.80.0(react@19.2.0))':
+ dependencies:
+ '@standard-schema/utils': 0.3.0
+ react-hook-form: 7.80.0(react@19.2.0)
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -8920,6 +8931,10 @@ snapshots:
dependencies:
react: 19.2.0
+ react-hook-form@7.80.0(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+
react-is@16.13.1: {}
react-medium-image-zoom@5.4.0(react-dom@19.1.1(react@19.2.0))(react@19.2.0):