Skip to content
Merged
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/chatty-seas-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@dynz/react-hook-form": minor
"dynz": minor
---

added discriminated union schema
67 changes: 65 additions & 2 deletions examples/example/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,70 @@
import * as d from "dynz";
import { runExample } from "./registration-form";

runExample();
// runExample();

async function oldExample() {
const schema = d.object({
type: d.options(["aluminium-side-wall", "glass-sliding-door"]),

aluminium_side_wall: d
.object({
shiplap_color: d.options(["color_a", "color_b"]),
})
.setIncluded(d.eq(d.ref("type"), "aluminium-side-wall")),

glass_sliding_door: d
.object({
rail_type: d.options(["2-rails", "3-rails", "4-rails", "5-rails", "6-rails"]),
})
.setIncluded(d.eq(d.ref("type"), "glass-sliding-door")),
});

const result = await d.validate(schema, undefined, {});

if (result.success) {
const values = result.values;
// console.log(result.values.wall)
}
}

async function runExample() {
const schema = d.object({
wall: d.discriminatedUnion("type", [
{
type: "aluminium-side-wall",
shiplap_color: d.options(["color_a", "color_b"]),
},
{
type: "glass-sliding-door",
foo: d.string().setRequired(false),
rail_type: d.options(["2-rails", "3-rails", "4-rails", "5-rails", "6-rails"]).setIncluded(false),
},
]),
});

const result = await d.validate(schema, undefined, {});

if (result.success) {
const values = result.values;

if (values.wall.type === "glass-sliding-door") {
values.wall.rail_type;
}
}
}

// console.log(res)

// if (res.success) {

// // if (res.values.foo.type === 1) {
// // const a = res.values.foo.foo
// // }

// }
// }

// foo().then(() => console.log('damn..'))

// console.log(`stringSchema result:`, d.validate(stringSchema, undefined, {
// foo: [{
Expand Down
19 changes: 19 additions & 0 deletions examples/next-example/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
"title": "Hello world!",
"about": "Go to the about page"
},
"contactForm": {
"name": {
"label": "Naam",
"placeholder": "Bijv. Jan"
},
"contactDetails": {
"type": {
"label": "Type"
},
"email": {
"label": "Email",
"placeholder": "Bijv. Jan"
},
"phone": {
"label": "Phone",
"placeholder": "Bijv. Jan"
}
}
},
"pensionPersonalInfo": {
"firstName": {
"label": "Voornaam",
Expand Down
2 changes: 1 addition & 1 deletion examples/next-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"lucide-react": "^0.542.0",
"next": "15.3.5",
"next-intl": "^4.3.4",
"react-hook-form": "^7.62.0",
"react-hook-form": "^7.75.0",
"tailwind-merge": "^3.3.1",
"zod": "^3.25.76"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client";

import { discriminatedUnion, literal, object, ref, string } from "dynz";
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";

const schema = object({
name: string(),
contactDetails: discriminatedUnion("type", [
{
type: "email",
email: string(),
},
{
type: "phone",
phone: string(),
},
]),
});

export default function Home() {
return (
<Card className="flex flex-col gap-4 max-w-100">
<CardContent className="gap-2">
<DynzForm name="contactForm" schema={schema}>
<DynzInput name="name" type="text" />
<DynzUnionKey name="contactDetails.type" />
<DynzInput name="contactDetails.email" type="text" />
<DynzInput name="contactDetails.phone" type="text" />
<Button type="submit">Submit</Button>
</DynzForm>
</CardContent>
</Card>
);
}
43 changes: 43 additions & 0 deletions examples/next-example/src/components/dynz/union-key.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useDiscriminatedUnionKeyValues } from "@dynz/react-hook-form";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "../ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
import { DynzFormField } from "./dynz-form-field";

export type DynzSelectProps = {
name: string;
};

export function DynzUnionKey({ name }: DynzSelectProps) {
// Get options from schema if not provided via props
const options = useDiscriminatedUnionKeyValues(name);

return (
<DynzFormField
name={name}
render={({ field, translations, required, readOnly }) => (
<FormItem>
<FormLabel>
{translations.label}
{required && " *"}
</FormLabel>
<Select onValueChange={field.onChange} value={field.value} disabled={readOnly}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder={translations.placeholder} />
</SelectTrigger>
</FormControl>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value?.toString()} value={option.value?.toString() || ""}>
{option.value?.toString()}
</SelectItem>
))}
</SelectContent>
</Select>
{translations.description && <FormDescription>{translations.description}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
);
}
24 changes: 24 additions & 0 deletions packages/dynz/src/conditions/get-condition-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,30 @@ function _getRulesDependenciesMap(schema: Schema, path: string, root: Schema): R
}
break;
}
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;
}

// add discriminated union key as a dependency
addDependencies(`${path}.${schema.key}`, [`${path}.${fieldKey}`]);

const childDependencies = _getRulesDependenciesMap(fieldSchema, `${path}.${fieldKey}`, root);
Object.assign(result.dependencies, childDependencies.dependencies);
for (const [dep, dependents] of Object.entries(childDependencies.reverse)) {
if (!result.reverse[dep]) {
result.reverse[dep] = new Set();
}
for (const dependent of dependents) {
result.reverse[dep].add(dependent);
}
}
}
}
break;
}
}

return result;
Expand Down
13 changes: 12 additions & 1 deletion packages/dynz/src/conditions/resolve-property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,20 @@ export function resolveProperty<T extends Schema>(
for (let i = 1; i <= segments.length; i++) {
const currentPath = segments.slice(0, i).join(".");
const nested = getNested(currentPath, context.schema, context.values);

// getNested returns null when the path cannot be resolved — this happens when
// navigating through a discriminated union whose discriminator value does not
// match any member (e.g. the field is empty or the current value is an excluded
// member). Treat this as "not accessible" → behave as if not included.
if (nested === null) {
return false;
}

const ret = _resolveProperty(nested.schema, property, currentPath, defaultValue, context);

if (!ret) return false;
if (ret === false) {
return false;
}
}

return _resolveProperty(context.schema, property, path, defaultValue, context);
Expand Down
13 changes: 12 additions & 1 deletion packages/dynz/src/functions/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,18 @@ export function unpackRef<T extends SchemaType = SchemaType>(
...expected: T[]
): ValueType<T> | undefined {
const absolutePath = ensureAbsolutePath(ref.path, path);
const { schema, value } = getNested(absolutePath, context.schema, context.values);

// getNested returns null when the path cannot be resolved — this happens when
// navigating through a discriminated union whose discriminator value does not
// match any member (e.g. the field is empty or the current value is an excluded
// member). Treat this as "not accessible" → behave as if not included.
const ret = getNested(absolutePath, context.schema, context.values);

if (ret === null) {
return undefined;
}

const { schema, value } = ret;

// only return when the schema is actually included
if (!isIncluded(context.schema, absolutePath, context.values)) {
Expand Down
55 changes: 55 additions & 0 deletions packages/dynz/src/schemas/discriminated-union/fluent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Predicate } from "../../functions";
import type { JsonRecord, Schema } from "../../types";
import { SchemaType } from "../../types";
import type { CheckMember, DiscriminatedUnionSchema } from "./types";

type SchemaMember = Record<string, Schema | string | number | boolean>;

export type DiscriminatedUnionFluent<
TKey extends string,
TSchemas extends SchemaMember[],
TProps,
> = DiscriminatedUnionSchema<TKey, TSchemas> &
TProps & {
setRequired: <P extends boolean | Predicate>(
value: P
) => DiscriminatedUnionFluent<TKey, TSchemas, TProps & { required: P }>;
optional: () => DiscriminatedUnionFluent<TKey, TSchemas, TProps & { required: false }>;
setMutable: <P extends boolean | Predicate>(
value: P
) => DiscriminatedUnionFluent<TKey, TSchemas, TProps & { mutable: P }>;
setIncluded: <P extends boolean | Predicate>(
value: P
) => DiscriminatedUnionFluent<TKey, TSchemas, TProps & { included: P }>;
setPrivate: <P extends boolean>(value: P) => DiscriminatedUnionFluent<TKey, TSchemas, TProps & { private: P }>;
setUi: <TUI extends JsonRecord>(config: TUI) => DiscriminatedUnionFluent<TKey, TSchemas, TProps & { ui: TUI }>;
};

function createFluent<TKey extends string, TMembers extends SchemaMember[], TProps>(
key: TKey,
schemas: TMembers,
props: TProps
): DiscriminatedUnionFluent<TKey, TMembers, TProps> {
const setProp = <K extends string, V>(k: K, v: V): DiscriminatedUnionFluent<TKey, TMembers, TProps & Record<K, V>> =>
createFluent(key, schemas, { ...props, [k]: v } as TProps & Record<K, V>);

return {
type: SchemaType.DISCRIMINATED_UNION,
key,
schemas,
...props,
setRequired: <P extends boolean | Predicate>(v: P) => setProp("required", v),
optional: () => setProp("required", false as false),
setMutable: <P extends boolean | Predicate>(v: P) => setProp("mutable", v),
setIncluded: <P extends boolean | Predicate>(v: P) => setProp("included", v),
setPrivate: <P extends boolean>(v: P) => setProp("private", v),
setUi: <TUI extends JsonRecord>(config: TUI) => setProp("ui", config),
} as DiscriminatedUnionFluent<TKey, TMembers, TProps>;
}

export function discriminatedUnion<
const TKey extends string,
const TSchemas extends { [I in keyof TSchemas]: CheckMember<TKey, TSchemas[I]> },
>(key: TKey, schemas: TSchemas): DiscriminatedUnionFluent<TKey, TSchemas & SchemaMember[], Record<never, never>> {
return createFluent(key, schemas as TSchemas & SchemaMember[], {} as Record<never, never>);
}
2 changes: 2 additions & 0 deletions packages/dynz/src/schemas/discriminated-union/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./fluent";
export * from "./types";
19 changes: 19 additions & 0 deletions packages/dynz/src/schemas/discriminated-union/types.ts
Original file line number Diff line number Diff line change
@@ -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<TKey extends string, T> = {
[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<string, Schema | string | number | boolean>[] = Record<
string,
Schema | string | number | boolean
>[],
> = BaseSchema<unknown, typeof SchemaType.DISCRIMINATED_UNION, never> & {
key: TKey;
schemas: [TSchemas] extends [never] ? Record<string, Schema | string | number | boolean>[] : TSchemas;
};
1 change: 1 addition & 0 deletions packages/dynz/src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export * from "./number";
export * from "./object";
export * from "./options";
export * from "./shared";
export * from "./discriminated-union";
export * from "./string";
Loading
Loading