Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fresh-llamas-strive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@dynz/react-hook-form": patch
"dynz": patch
---

added dynamic discriminator keys
3 changes: 3 additions & 0 deletions examples/next-example/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"label": "Naam",
"placeholder": "Bijv. Jan"
},
"allowPhone": {
"label": "Allow phone contact"
},
"contactDetails": {
"type": {
"label": "Type"
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
},
]),
Expand All @@ -27,6 +33,7 @@ export default function Home() {
<CardContent className="gap-2">
<DynzForm name="contactForm" schema={schema}>
<DynzInput name="name" type="text" />
<DynzCheckbox name="allowPhone" />
<DynzUnionKey name="contactDetails.type" />
<DynzInput name="contactDetails.email" type="text" />
<DynzInput name="contactDetails.phone" type="text" />
Expand Down
6 changes: 5 additions & 1 deletion examples/next-example/src/components/dynz/union-key.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ export function DynzUnionKey({ name }: DynzSelectProps) {
</FormControl>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value?.toString()} value={option.value?.toString() || ""}>
<SelectItem
key={option.value?.toString()}
value={option.value?.toString() || ""}
disabled={!option.enabled}
>
{option.value?.toString()}
</SelectItem>
))}
Expand Down
72 changes: 71 additions & 1 deletion packages/dynz/src/conditions/get-condition-dependencies.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -258,4 +258,74 @@ 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"]));
});
});

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"]);
});
});
});
32 changes: 25 additions & 7 deletions packages/dynz/src/conditions/get-condition-dependencies.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ParamaterValue, Predicate, Transformer } from "../functions";
import { isReference } from "../reference";
import type { Rule } from "../rules";
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";

/**
Expand Down Expand Up @@ -61,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)];
}
Expand Down Expand Up @@ -117,14 +129,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);
}
};
Expand Down Expand Up @@ -158,7 +171,12 @@ 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") {
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 (fieldSchema.enabled !== undefined && typeof fieldSchema.enabled !== "boolean") {
addDependencies(`${path}.${schema.key}`, getConditionDependencies(fieldSchema.enabled, path, root));
}
continue;
}

Expand Down
11 changes: 10 additions & 1 deletion packages/dynz/src/conditions/resolve-property.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Schema>(
property: "required" | "mutable" | "included",
Expand All @@ -27,6 +27,15 @@ export function resolveProperty<T extends Schema>(
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) {
Expand Down
11 changes: 10 additions & 1 deletion packages/dynz/src/functions/resolve.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -34,6 +34,15 @@ export function unpackRef<T extends SchemaType = SchemaType>(
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<T>
| undefined;
}

if (schema.type === "expression") {
return resolve(schema.value, absolutePath, context) as ValueType<T>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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 "..";
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({
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);
});
});
});
Loading
Loading