From e388748fbe4ca484975b8048837871943fd47be2 Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Tue, 23 Jun 2026 08:40:57 +0200 Subject: [PATCH 01/11] initial --- examples/example/src/index.ts | 31 ++++- .../app/[locale]/discriminated-union/page.tsx | 38 +++++++ .../src/components/dynz/dynz-form-field.tsx | 8 +- .../next-example/src/components/dynz/form.tsx | 4 +- .../src/components/dynz/union-key.tsx | 48 ++++++++ .../conditions/get-condition-dependencies.ts | 17 +++ .../dynz/src/conditions/resolve-property.ts | 53 ++++++++- .../src/schemas/discriminated-union/fluent.ts | 53 +++++++++ .../src/schemas/discriminated-union/index.ts | 2 + .../src/schemas/discriminated-union/types.ts | 19 ++++ packages/dynz/src/schemas/index.ts | 1 + packages/dynz/src/types/schema.ts | 17 ++- .../dynz/src/utils/find-schema-by-path.ts | 10 ++ packages/dynz/src/utils/get-nested.ts | 53 ++++++++- packages/dynz/src/validate/validate-type.ts | 4 + packages/dynz/src/validate/validate.ts | 107 ++++++++++++------ packages/react-hook-form/src/hooks/index.ts | 1 + .../use-discriminated-union-key-values.ts | 43 +++++++ 18 files changed, 455 insertions(+), 54 deletions(-) create mode 100644 examples/next-example/src/app/[locale]/discriminated-union/page.tsx create mode 100644 examples/next-example/src/components/dynz/union-key.tsx create mode 100644 packages/dynz/src/schemas/discriminated-union/fluent.ts create mode 100644 packages/dynz/src/schemas/discriminated-union/index.ts create mode 100644 packages/dynz/src/schemas/discriminated-union/types.ts create mode 100644 packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts diff --git a/examples/example/src/index.ts b/examples/example/src/index.ts index 1b0594a..88ef98e 100644 --- a/examples/example/src/index.ts +++ b/examples/example/src/index.ts @@ -1,7 +1,36 @@ import * as d from "dynz"; import { runExample } from "./registration-form"; -runExample(); +// runExample(); + +const unionSchema = d.object({ + bar: d.number(), + foo: d.discriminatedUnion('type', [d.object({ + type: d.literal('left'), + foo: d.string(), + }).setIncluded(false), d.object({ + type: d.literal('right'), + bar: d.string() + })]) +}) + +const fooIncluded = d.isIncluded(unionSchema, '$.foo.foo', { foo: { type: 'left', }, bar: 1 }) + +type Foo = d.SchemaValues + +console.log('fooIncluded:', fooIncluded) + +d.validate(unionSchema, undefined, { + foo: { + type: 'left', + foo: 'sfs' + }, + bar: 1 +}).then((e) => { + + console.log(`stringSchema result:`, e); +}) + // console.log(`stringSchema result:`, d.validate(stringSchema, undefined, { // foo: [{ diff --git a/examples/next-example/src/app/[locale]/discriminated-union/page.tsx b/examples/next-example/src/app/[locale]/discriminated-union/page.tsx new file mode 100644 index 0000000..417514d --- /dev/null +++ b/examples/next-example/src/app/[locale]/discriminated-union/page.tsx @@ -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", [ + object({ + type: literal("email"), + email: string(), + }), + object({ + type: literal("phone"), + phone: string(), + }), + ]), +}); + +export default function Home() { + return ( + + + + + + + + + + + + ); +} diff --git a/examples/next-example/src/components/dynz/dynz-form-field.tsx b/examples/next-example/src/components/dynz/dynz-form-field.tsx index 0a4d1b6..c3b9c55 100644 --- a/examples/next-example/src/components/dynz/dynz-form-field.tsx +++ b/examples/next-example/src/components/dynz/dynz-form-field.tsx @@ -18,6 +18,7 @@ type TranslationsObject = { type DynzFormFieldProps = { name: string; + rhfName?: string render: ({ field, translations, @@ -34,7 +35,7 @@ type DynzFormFieldProps = { }) => React.ReactElement; }; -export const DynzFormField = ({ name, render }: DynzFormFieldProps) => { +export const DynzFormField = ({ name, rhfName, render }: DynzFormFieldProps) => { const { control, getDependencies, name: i18nPath } = useDynzFormContext(); const t = useTranslations(); const isMutable = useIsMutable(name); @@ -58,6 +59,7 @@ export const DynzFormField = ({ name, render }: DynzFormFieldProps) => { return ( { const DynzFormFieldInner = memo( ({ name, + rhfName, control, dependencies, readOnly, @@ -84,6 +87,7 @@ const DynzFormFieldInner = memo( render, }: { name: string; + rhfName?: string; control: Control; dependencies: string[] | undefined; readOnly: boolean; @@ -99,7 +103,7 @@ const DynzFormFieldInner = memo( return ( , A extends SchemaValues { const customErrorMessagePath = `${name}.${error.path.slice(2)}.errors.${error.customCode}`; - + console.log(error) return t.has(customErrorMessagePath) ? t(customErrorMessagePath, error as unknown as Record) : t(`errors.${error.customCode}`, error as unknown as Record); @@ -45,7 +45,7 @@ export function DynzForm, A extends SchemaValues -
onSubmit?.(values))}> + onSubmit?.(values), (err) => console.log(err))}> {children}
diff --git a/examples/next-example/src/components/dynz/union-key.tsx b/examples/next-example/src/components/dynz/union-key.tsx new file mode 100644 index 0000000..6431bbd --- /dev/null +++ b/examples/next-example/src/components/dynz/union-key.tsx @@ -0,0 +1,48 @@ +import { useDiscriminatedUnionKeyValues, useDynzFormContext } from "@dynz/react-hook-form"; +import { type DiscriminatedUnionSchema, findSchemaByPath, type OptionsSchema, SchemaType } from "dynz"; +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) { + // const { schema } = useDynzFormContext(); + // const innerSchema = findSchemaByPath(`$.${name}`, schema, SchemaType.DISCRIMINATED_UNION); + + // Get options from schema if not provided via props + const options = useDiscriminatedUnionKeyValues(name.split(".").slice(0, -1).join(".")); + + return ( + ( + + + {translations.label} + {required && " *"} + + + {translations.description && {translations.description}} + + + )} + /> + ); +} diff --git a/packages/dynz/src/conditions/get-condition-dependencies.ts b/packages/dynz/src/conditions/get-condition-dependencies.ts index 0fd91cd..18b9023 100644 --- a/packages/dynz/src/conditions/get-condition-dependencies.ts +++ b/packages/dynz/src/conditions/get-condition-dependencies.ts @@ -155,6 +155,23 @@ 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.fields)) { + 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; diff --git a/packages/dynz/src/conditions/resolve-property.ts b/packages/dynz/src/conditions/resolve-property.ts index a518c51..6668f1b 100644 --- a/packages/dynz/src/conditions/resolve-property.ts +++ b/packages/dynz/src/conditions/resolve-property.ts @@ -1,6 +1,7 @@ import { type Predicate, resolvePredicate } from "../functions"; -import type { ResolveContext, Schema } from "../types"; +import { type ResolveContext, type Schema, SchemaType } from "../types"; import { getNested } from "../utils"; +import { isObject } from "../validate/validate-type"; export function resolveProperty( property: "required" | "mutable" | "included", @@ -14,13 +15,59 @@ export function resolveProperty( const segments = path.split(/[.[\]]/).filter(Boolean); - // Check each ancestor path prefix (starting from first real field, skipping root "$") + // Walk every ancestor prefix from the root down to (and including) the target path. + // For each prefix we check whether the schema at that level has the property set — + // e.g. an ancestor object being not-included makes all its children not-included too. 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) { + return false; + } + + if ( + nested.schema.type === SchemaType.DISCRIMINATED_UNION && + i < segments.length && + segments[i] !== nested.schema.key && + isObject(nested.value) + ) { + const key = nested.schema.key; + const discriminatorValue = nested.value[key]; + + const matchingMember = nested.schema.schemas.find((s) => { + const discriminatorField = s.fields[key]; + if (discriminatorField === undefined) { + return false; + } + + if (discriminatorField.type !== SchemaType.LITERAL) { + // only literal type discriminators are alowed + return false; + } + + return discriminatorField.value === discriminatorValue; + }); + + // If we found a matching member, check whether it has the property set. + // A non-included member means none of its fields should be rendered — + // return false so the caller (e.g. isIncluded) hides those fields. + if (matchingMember !== undefined) { + if (!_resolveProperty(matchingMember, property, currentPath, defaultValue, context)) { + return false; + } + } + } } return _resolveProperty(context.schema, property, path, defaultValue, context); diff --git a/packages/dynz/src/schemas/discriminated-union/fluent.ts b/packages/dynz/src/schemas/discriminated-union/fluent.ts new file mode 100644 index 0000000..3c8fef5 --- /dev/null +++ b/packages/dynz/src/schemas/discriminated-union/fluent.ts @@ -0,0 +1,53 @@ +import type { Predicate } from "../../functions"; +import type { JsonRecord } from "../../types"; +import { SchemaType } from "../../types"; +import type { DiscriminatedUnionSchema, ObjectSchemaWithDiscriminator } from "./types"; + +export type DiscriminatedUnionFluent< + TKey extends string, + TSchemas extends ObjectSchemaWithDiscriminator[], + TProps, +> = DiscriminatedUnionSchema & + 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[], TProps>( + 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 ObjectSchemaWithDiscriminator[], +>(key: TKey, schemas: TSchemas): DiscriminatedUnionFluent> { + return createFluent(key, schemas, {} 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..0b1ba7e --- /dev/null +++ b/packages/dynz/src/schemas/discriminated-union/types.ts @@ -0,0 +1,19 @@ +import type { BaseSchema, Schema, SchemaType } from "../../types"; +import type { LiteralSchema } from "../literal"; +import type { ObjectSchema } from "../object"; + +// Constraint used by the discriminatedUnion() builder to enforce a literal discriminator field. +// Only used at the call site — NOT as the TSchemas constraint in DiscriminatedUnionSchema, +// because when TKey = string the intersection collapses to Record, +// which incorrectly requires every field to be a LiteralSchema. +export type ObjectSchemaWithDiscriminator = ObjectSchema< + Record & Record +>; + +export type DiscriminatedUnionSchema< + TKey extends string = string, + TSchemas extends ObjectSchema[] = ObjectSchema[], +> = BaseSchema & { + key: TKey; + schemas: [TSchemas] extends [never] ? ObjectSchema[] : 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 3f7b8b7..fd8dddf 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, EnumValue, EnumValues, FileSchema, @@ -30,6 +31,7 @@ export const SchemaType = { FILE: "file", EXPRESSION: "expression", LITERAL: "literal", + DISCRIMINATED_UNION: "discriminated_union", } as const; export type SchemaType = EnumValues; @@ -60,7 +62,8 @@ export type Schema = | DateSchema | EnumSchema | ExpressionSchema - | LiteralSchema; + | LiteralSchema + | DiscriminatedUnionSchema; export type IsIncluded = T extends { included: true } ? true @@ -114,7 +117,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; @@ -140,8 +145,10 @@ export type SchemaValuesInternal = T extends ObjectSchema> : T extends OptionsSchema ? Unpacked - : 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..0029193 100644 --- a/packages/dynz/src/utils/find-schema-by-path.ts +++ b/packages/dynz/src/utils/find-schema-by-path.ts @@ -27,6 +27,16 @@ export function findSchemaByPath(path: string, schema return childSchema; } + if (prev.type === SchemaType.DISCRIMINATED_UNION) { + for (const member of prev.schemas) { + const childSchema = member.fields[cur]; + if (childSchema !== undefined) { + 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..be179d0 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,50 @@ export function getNested( }; } + if (acc.schema.type === SchemaType.DISCRIMINATED_UNION) { + 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) => { + const discriminatorField = s.fields[key]; + if (discriminatorField === undefined || discriminatorField.type !== SchemaType.LITERAL) { + return false; + } + + return discriminatorField.value === discriminatorValue; + }); + + if (matchingMember === undefined) { + return null; + } + + const childSchema = matchingMember.fields[cur]; + + if (childSchema === undefined) { + throw new Error(`No schema found for path ${path}`); + } + + 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..67ce19f 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"; @@ -326,40 +326,77 @@ 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)) { + 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} is not an object`, + }, + ], + }; + } + + const discriminatorValue = newValue[schema.key]; + const matchingMember = schema.schemas.find((s) => { + const discriminatorField = s.fields[schema.key]; + if (discriminatorField === undefined || discriminatorField.type !== SchemaType.LITERAL) return false; + return discriminatorField.value === 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)})`, + }, + ], + }; + } + + /** + * A matched member can be conditionally excluded via setIncluded(). Unlike a regular + * not-included field (which is silently stripped), a non-included member means the + * submitted discriminator value is not a valid choice — treat it as a hard error. + */ + if (!_resolveProperty(matchingMember as unknown as Schema, "included", path, true, context)) { + return { + success: false, + errors: [ + { + path, + schema, + value: newValue, + current: currentValue, + customCode: ErrorCode.INCLUDED, + code: ErrorCode.INCLUDED, + message: `The value for schema ${path} matches a member of the discriminated union that is not included (key: ${schema.key}=${String(discriminatorValue)})`, + }, + ], + }; + } + + return _validate(matchingMember as unknown as Schema, { current: currentValue, new: newValue }, path, context); + } 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-discriminated-union-key-values.ts b/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts new file mode 100644 index 0000000..28025be --- /dev/null +++ b/packages/react-hook-form/src/hooks/use-discriminated-union-key-values.ts @@ -0,0 +1,43 @@ +import { + _resolveProperty, + type DiscriminatedUnionSchema, + findSchemaByPath, + getConditionDependencies, + type LiteralSchema, + SchemaType, +} from "dynz"; +import { useMemo } from "react"; +import { useWatch } from "react-hook-form"; +import { useDynzFormContext } from "./use-dynz-form-context"; + +export function useDiscriminatedUnionKeyValues(name: string) { + const { control, getValues, schema } = useDynzFormContext(); + + const unionSchema = findSchemaByPath(`$.${name}`, schema, SchemaType.DISCRIMINATED_UNION); + + const dependencies = unionSchema.schemas.reduce((acc, member) => { + if (member.included !== undefined && typeof member.included !== "boolean") { + acc.push(...getConditionDependencies(member.included, `$.${name}`, schema).map((d) => d.slice(2))); + } + return acc; + }, []); + + const watchedValues = useWatch({ + name: dependencies, + control, + compute: (val) => JSON.stringify(val), + disabled: dependencies.length === 0, + }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: watchedValues triggers the re-evaluation + return useMemo(() => { + const values = getValues(); + const context = { schema, values }; + const key = unionSchema.key; + + return unionSchema.schemas.map((member) => ({ + enabled: _resolveProperty(member, "included", `$.${name}`, true, context), + value: (member.fields[key] as LiteralSchema).value, + })); + }, [schema, unionSchema, name, watchedValues]); +} From 791f885af39fc5c96cf6aa40ae51ef678c59d075 Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Wed, 24 Jun 2026 10:08:57 +0200 Subject: [PATCH 02/11] update --- examples/example/src/index.ts | 58 ++++++++----- examples/next-example/messages/en.json | 19 +++++ examples/next-example/package.json | 2 +- .../app/[locale]/discriminated-union/page.tsx | 12 +-- .../src/components/dynz/union-key.tsx | 13 ++- .../conditions/get-condition-dependencies.ts | 14 ++- .../dynz/src/conditions/resolve-property.ts | 45 ++-------- packages/dynz/src/functions/resolve.ts | 8 +- .../src/schemas/discriminated-union/fluent.ts | 33 +++++-- .../src/schemas/discriminated-union/types.ts | 22 ++--- packages/dynz/src/types/schema.ts | 17 +++- .../dynz/src/utils/find-schema-by-path.ts | 10 ++- packages/dynz/src/utils/get-nested.ts | 30 ++++--- packages/dynz/src/validate/validate.ts | 85 ++++++++++--------- .../src/hooks/use-conditional-property.ts | 12 ++- .../use-discriminated-union-key-values.ts | 35 ++------ 16 files changed, 236 insertions(+), 179 deletions(-) diff --git a/examples/example/src/index.ts b/examples/example/src/index.ts index 88ef98e..658791d 100644 --- a/examples/example/src/index.ts +++ b/examples/example/src/index.ts @@ -1,35 +1,49 @@ import * as d from "dynz"; -import { runExample } from "./registration-form"; + // runExample(); const unionSchema = d.object({ - bar: d.number(), - foo: d.discriminatedUnion('type', [d.object({ - type: d.literal('left'), - foo: d.string(), - }).setIncluded(false), d.object({ - type: d.literal('right'), - bar: d.string() - })]) -}) - -const fooIncluded = d.isIncluded(unionSchema, '$.foo.foo', { foo: { type: 'left', }, bar: 1 }) + name: d.string(), + contactDetails: d.discriminatedUnion("type", [ + { + type: "email", + email: d.string(), + }, + { + type: "phone", + phone: d.string(), + }, + ]), +}); + +const fooIncluded = d.isIncluded(unionSchema, '$.contactDetails.type', { contactDetails: { type: "email", }, name: "da" }) +const fooFooIncluded = d.isIncluded(unionSchema, '$.contactDetails.email', { contactDetails: { type: "email", }, name: "fo" }) type Foo = d.SchemaValues -console.log('fooIncluded:', fooIncluded) +console.log('fooIncluded:', fooIncluded, fooFooIncluded) +// async function foo() { +// const res = await d.validate(unionSchema, undefined, { +// foo: { +// type: 1, +// foo: 1 +// }, +// bar: 1 +// }) -d.validate(unionSchema, undefined, { - foo: { - type: 'left', - foo: 'sfs' - }, - bar: 1 -}).then((e) => { +// console.log(res) - console.log(`stringSchema result:`, e); -}) +// 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, { diff --git a/examples/next-example/messages/en.json b/examples/next-example/messages/en.json index 2adcc25..e119d72 100644 --- a/examples/next-example/messages/en.json +++ b/examples/next-example/messages/en.json @@ -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", diff --git a/examples/next-example/package.json b/examples/next-example/package.json index 4881dda..093de86 100644 --- a/examples/next-example/package.json +++ b/examples/next-example/package.json @@ -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" }, 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 417514d..2e748ae 100644 --- a/examples/next-example/src/app/[locale]/discriminated-union/page.tsx +++ b/examples/next-example/src/app/[locale]/discriminated-union/page.tsx @@ -10,14 +10,14 @@ import { Card, CardContent } from "@/components/ui/card"; const schema = object({ name: string(), contactDetails: discriminatedUnion("type", [ - object({ - type: literal("email"), + { + type: "email", email: string(), - }), - object({ - type: literal("phone"), + }, + { + type: "phone", phone: string(), - }), + }, ]), }); diff --git a/examples/next-example/src/components/dynz/union-key.tsx b/examples/next-example/src/components/dynz/union-key.tsx index 6431bbd..174dc0f 100644 --- a/examples/next-example/src/components/dynz/union-key.tsx +++ b/examples/next-example/src/components/dynz/union-key.tsx @@ -9,16 +9,21 @@ export type DynzSelectProps = { }; export function DynzUnionKey({ name }: DynzSelectProps) { - // const { schema } = useDynzFormContext(); + const { watch } = useDynzFormContext(); + + const vals = watch() + console.log('val', vals) + // const innerSchema = findSchemaByPath(`$.${name}`, schema, SchemaType.DISCRIMINATED_UNION); // Get options from schema if not provided via props - const options = useDiscriminatedUnionKeyValues(name.split(".").slice(0, -1).join(".")); + const options = useDiscriminatedUnionKeyValues(name); + console.log('options', options) return ( ( diff --git a/packages/dynz/src/conditions/get-condition-dependencies.ts b/packages/dynz/src/conditions/get-condition-dependencies.ts index 18b9023..b3a375a 100644 --- a/packages/dynz/src/conditions/get-condition-dependencies.ts +++ b/packages/dynz/src/conditions/get-condition-dependencies.ts @@ -112,6 +112,7 @@ export function getRulesDependencies(schema: Schema, path: string): string[] { } function _getRulesDependenciesMap(schema: Schema, path: string, root: Schema): RulesDependencyMap { + console.log("_getRulesDependenciesMap", schema, path); const result: RulesDependencyMap = { dependencies: {}, reverse: {}, @@ -157,7 +158,16 @@ 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.fields)) { + for (const [fieldKey, fieldSchema] of Object.entries(member)) { + if (typeof fieldSchema === "string" || typeof fieldSchema === "number" || typeof fieldSchema === "boolean") { + continue; + } + + console.log("building map: ", schema); + + // 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)) { @@ -174,6 +184,8 @@ function _getRulesDependenciesMap(schema: Schema, path: string, root: Schema): R } } + console.log("result", result); + return result; } diff --git a/packages/dynz/src/conditions/resolve-property.ts b/packages/dynz/src/conditions/resolve-property.ts index 6668f1b..a41bb94 100644 --- a/packages/dynz/src/conditions/resolve-property.ts +++ b/packages/dynz/src/conditions/resolve-property.ts @@ -1,7 +1,6 @@ import { type Predicate, resolvePredicate } from "../functions"; -import { type ResolveContext, type Schema, SchemaType } from "../types"; +import type { ResolveContext, Schema } from "../types"; import { getNested } from "../utils"; -import { isObject } from "../validate/validate-type"; export function resolveProperty( property: "required" | "mutable" | "included", @@ -15,9 +14,7 @@ export function resolveProperty( const segments = path.split(/[.[\]]/).filter(Boolean); - // Walk every ancestor prefix from the root down to (and including) the target path. - // For each prefix we check whether the schema at that level has the property set — - // e.g. an ancestor object being not-included makes all its children not-included too. + // Check each ancestor path prefix (starting from first real field, skipping root "$") for (let i = 1; i <= segments.length; i++) { const currentPath = segments.slice(0, i).join("."); const nested = getNested(currentPath, context.schema, context.values); @@ -27,47 +24,15 @@ export function resolveProperty( // 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) { + console.log("reslolveProperty === null: ", path); return false; } const ret = _resolveProperty(nested.schema, property, currentPath, defaultValue, context); - - if (!ret) { + console.log("ret: ", path, ret); + if (ret === false) { return false; } - - if ( - nested.schema.type === SchemaType.DISCRIMINATED_UNION && - i < segments.length && - segments[i] !== nested.schema.key && - isObject(nested.value) - ) { - const key = nested.schema.key; - const discriminatorValue = nested.value[key]; - - const matchingMember = nested.schema.schemas.find((s) => { - const discriminatorField = s.fields[key]; - if (discriminatorField === undefined) { - return false; - } - - if (discriminatorField.type !== SchemaType.LITERAL) { - // only literal type discriminators are alowed - return false; - } - - return discriminatorField.value === discriminatorValue; - }); - - // If we found a matching member, check whether it has the property set. - // A non-included member means none of its fields should be rendered — - // return false so the caller (e.g. isIncluded) hides those fields. - if (matchingMember !== undefined) { - if (!_resolveProperty(matchingMember, property, currentPath, defaultValue, context)) { - return false; - } - } - } } return _resolveProperty(context.schema, property, path, defaultValue, context); diff --git a/packages/dynz/src/functions/resolve.ts b/packages/dynz/src/functions/resolve.ts index 87ac0cf..1a86d76 100644 --- a/packages/dynz/src/functions/resolve.ts +++ b/packages/dynz/src/functions/resolve.ts @@ -16,7 +16,13 @@ export function unpackRef( ...expected: T[] ): ValueType | undefined { const absolutePath = ensureAbsolutePath(ref.path, path); - const { schema, value } = getNested(absolutePath, context.schema, context.values); + 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)) { diff --git a/packages/dynz/src/schemas/discriminated-union/fluent.ts b/packages/dynz/src/schemas/discriminated-union/fluent.ts index 3c8fef5..3ab0501 100644 --- a/packages/dynz/src/schemas/discriminated-union/fluent.ts +++ b/packages/dynz/src/schemas/discriminated-union/fluent.ts @@ -1,11 +1,15 @@ import type { Predicate } from "../../functions"; -import type { JsonRecord } from "../../types"; +import type { JsonRecord, Schema } from "../../types"; import { SchemaType } from "../../types"; -import type { DiscriminatedUnionSchema, ObjectSchemaWithDiscriminator } from "./types"; +import { object } from "../object"; +import { string } from "../string/fluent"; +import type { CheckMember, DiscriminatedUnionSchema } from "./types"; + +type SchemaMember = Record; export type DiscriminatedUnionFluent< TKey extends string, - TSchemas extends ObjectSchemaWithDiscriminator[], + TSchemas extends SchemaMember[], TProps, > = DiscriminatedUnionSchema & TProps & { @@ -23,7 +27,7 @@ export type DiscriminatedUnionFluent< setUi: (config: TUI) => DiscriminatedUnionFluent; }; -function createFluent[], TProps>( +function createFluent( key: TKey, schemas: TMembers, props: TProps @@ -47,7 +51,22 @@ function createFluent[], ->(key: TKey, schemas: TSchemas): DiscriminatedUnionFluent> { - return createFluent(key, schemas, {} as Record); + const TSchemas extends { [I in keyof TSchemas]: CheckMember }, +>(key: TKey, schemas: TSchemas): DiscriminatedUnionFluent> { + return createFluent(key, schemas as TSchemas & SchemaMember[], {} as Record); } + +// LEAVE THIS HERE AS AN EXAMPLE FOR NOW +const unionSchema = object({ + foo: string(), + bar: discriminatedUnion("type", [ + { + type: "left", + foo: string(), + }, + { + type: "right", + bar: string(), + }, + ]), +}); diff --git a/packages/dynz/src/schemas/discriminated-union/types.ts b/packages/dynz/src/schemas/discriminated-union/types.ts index 0b1ba7e..5e05ac8 100644 --- a/packages/dynz/src/schemas/discriminated-union/types.ts +++ b/packages/dynz/src/schemas/discriminated-union/types.ts @@ -1,19 +1,19 @@ import type { BaseSchema, Schema, SchemaType } from "../../types"; -import type { LiteralSchema } from "../literal"; -import type { ObjectSchema } from "../object"; -// Constraint used by the discriminatedUnion() builder to enforce a literal discriminator field. -// Only used at the call site — NOT as the TSchemas constraint in DiscriminatedUnionSchema, -// because when TKey = string the intersection collapses to Record, -// which incorrectly requires every field to be a LiteralSchema. -export type ObjectSchemaWithDiscriminator = ObjectSchema< - Record & Record ->; +// 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 ObjectSchema[] = ObjectSchema[], + TSchemas extends Record[] = Record< + string, + Schema | string | number | boolean + >[], > = BaseSchema & { key: TKey; - schemas: [TSchemas] extends [never] ? ObjectSchema[] : TSchemas; + schemas: [TSchemas] extends [never] ? Record[] : TSchemas; }; diff --git a/packages/dynz/src/types/schema.ts b/packages/dynz/src/types/schema.ts index fd8dddf..3d13da6 100644 --- a/packages/dynz/src/types/schema.ts +++ b/packages/dynz/src/types/schema.ts @@ -137,6 +137,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 @@ -145,8 +158,8 @@ export type SchemaValuesInternal = T extends ObjectSchema> : T extends OptionsSchema ? Unpacked - : T extends DiscriminatedUnionSchema - ? MakeOptional> + : T extends DiscriminatedUnionSchema + ? MakeOptional> : T extends LiteralSchema ? MakeOptional : MakeOptional>; diff --git a/packages/dynz/src/utils/find-schema-by-path.ts b/packages/dynz/src/utils/find-schema-by-path.ts index 0029193..3246c55 100644 --- a/packages/dynz/src/utils/find-schema-by-path.ts +++ b/packages/dynz/src/utils/find-schema-by-path.ts @@ -29,8 +29,16 @@ export function findSchemaByPath(path: string, schema if (prev.type === SchemaType.DISCRIMINATED_UNION) { for (const member of prev.schemas) { - const childSchema = member.fields[cur]; + const childSchema = member[cur]; if (childSchema !== undefined) { + if ( + typeof childSchema === "boolean" || + typeof childSchema === "number" || + typeof childSchema === "string" + ) { + return prev; + } + return childSchema; } } diff --git a/packages/dynz/src/utils/get-nested.ts b/packages/dynz/src/utils/get-nested.ts index be179d0..3a66a28 100644 --- a/packages/dynz/src/utils/get-nested.ts +++ b/packages/dynz/src/utils/get-nested.ts @@ -50,6 +50,15 @@ export function getNested( } if (acc.schema.type === SchemaType.DISCRIMINATED_UNION) { + // if the key is referenced return the schema of the union type + console.log("curssss", cur, acc.schema.key); + 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; } @@ -58,24 +67,25 @@ export function getNested( 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) => { - const discriminatorField = s.fields[key]; - if (discriminatorField === undefined || discriminatorField.type !== SchemaType.LITERAL) { - return false; - } - return discriminatorField.value === discriminatorValue; - }); + const discriminatorValue = acc.value[key]; + const matchingMember = acc.schema.schemas.find((s) => s[key] === discriminatorValue); if (matchingMember === undefined) { return null; } - const childSchema = matchingMember.fields[cur]; + const childSchema = matchingMember[cur]; if (childSchema === undefined) { - throw new Error(`No schema found for path ${path}`); + 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 { diff --git a/packages/dynz/src/validate/validate.ts b/packages/dynz/src/validate/validate.ts index 67ce19f..f80b8b3 100644 --- a/packages/dynz/src/validate/validate.ts +++ b/packages/dynz/src/validate/validate.ts @@ -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"; @@ -331,29 +332,15 @@ export async function _validate( */ if (schema.type === SchemaType.DISCRIMINATED_UNION) { if (!isObject(newValue)) { - 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} is not an object`, - }, - ], - }; + 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) => { - const discriminatorField = s.fields[schema.key]; - if (discriminatorField === undefined || discriminatorField.type !== SchemaType.LITERAL) return false; - return discriminatorField.value === discriminatorValue; - }); + const matchingMember = schema.schemas.find((s) => s[schema.key] === discriminatorValue); if (matchingMember === undefined) { return { @@ -373,29 +360,45 @@ export async function _validate( }; } - /** - * A matched member can be conditionally excluded via setIncluded(). Unlike a regular - * not-included field (which is silently stripped), a non-included member means the - * submitted discriminator value is not a valid choice — treat it as a hard error. - */ - if (!_resolveProperty(matchingMember as unknown as Schema, "included", path, true, context)) { - return { - success: false, - errors: [ - { - path, - schema, - value: newValue, - current: currentValue, - customCode: ErrorCode.INCLUDED, - code: ErrorCode.INCLUDED, - message: `The value for schema ${path} matches a member of the discriminated union that is not included (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 _validate(matchingMember as unknown as Schema, { current: currentValue, new: newValue }, path, context); + return acc; } return { 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..0edd523 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,7 +13,13 @@ 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); + + if (ancestor.type === SchemaType.DISCRIMINATED_UNION) { + return [`${ancestorPath}.${ancestor.key}`.slice(2)]; + } + + const ancestorPropertyValue = ancestor[property]; if (ancestorPropertyValue === undefined || typeof ancestorPropertyValue === "boolean") { return []; } @@ -43,6 +49,8 @@ export function useConditionalProperty( // otherwise the hook never re-renders when the ancestor's controlling field changes. const dependencies = [...new Set(paths.flatMap((path) => getPropertyDependencies(path, property, schema)))]; + console.log("useConditionalProperty:", name, property, dependencies); + // Watch is just here to trigger a rerender when a value gets updated const watchedValues = useWatch({ name: dependencies, 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 28025be..c599323 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,43 +1,18 @@ -import { - _resolveProperty, - type DiscriminatedUnionSchema, - findSchemaByPath, - getConditionDependencies, - type LiteralSchema, - SchemaType, -} from "dynz"; +import { type DiscriminatedUnionSchema, findSchemaByPath, SchemaType } from "dynz"; import { useMemo } from "react"; -import { useWatch } from "react-hook-form"; import { useDynzFormContext } from "./use-dynz-form-context"; export function useDiscriminatedUnionKeyValues(name: string) { - const { control, getValues, schema } = useDynzFormContext(); + const { schema } = useDynzFormContext(); const unionSchema = findSchemaByPath(`$.${name}`, schema, SchemaType.DISCRIMINATED_UNION); - const dependencies = unionSchema.schemas.reduce((acc, member) => { - if (member.included !== undefined && typeof member.included !== "boolean") { - acc.push(...getConditionDependencies(member.included, `$.${name}`, schema).map((d) => d.slice(2))); - } - return acc; - }, []); - - const watchedValues = useWatch({ - name: dependencies, - control, - compute: (val) => JSON.stringify(val), - disabled: dependencies.length === 0, - }); - - // biome-ignore lint/correctness/useExhaustiveDependencies: watchedValues triggers the re-evaluation return useMemo(() => { - const values = getValues(); - const context = { schema, values }; const key = unionSchema.key; return unionSchema.schemas.map((member) => ({ - enabled: _resolveProperty(member, "included", `$.${name}`, true, context), - value: (member.fields[key] as LiteralSchema).value, + enabled: true, + value: member[key] as string | number | boolean, })); - }, [schema, unionSchema, name, watchedValues]); + }, [unionSchema]); } From 0c79ea4dd9908578d93982c81d0f7b08068b9383 Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Wed, 24 Jun 2026 10:09:16 +0200 Subject: [PATCH 03/11] update --- .../src/schemas/discriminated-union/fluent.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/dynz/src/schemas/discriminated-union/fluent.ts b/packages/dynz/src/schemas/discriminated-union/fluent.ts index 3ab0501..be8da09 100644 --- a/packages/dynz/src/schemas/discriminated-union/fluent.ts +++ b/packages/dynz/src/schemas/discriminated-union/fluent.ts @@ -1,8 +1,6 @@ import type { Predicate } from "../../functions"; import type { JsonRecord, Schema } from "../../types"; import { SchemaType } from "../../types"; -import { object } from "../object"; -import { string } from "../string/fluent"; import type { CheckMember, DiscriminatedUnionSchema } from "./types"; type SchemaMember = Record; @@ -55,18 +53,3 @@ export function discriminatedUnion< >(key: TKey, schemas: TSchemas): DiscriminatedUnionFluent> { return createFluent(key, schemas as TSchemas & SchemaMember[], {} as Record); } - -// LEAVE THIS HERE AS AN EXAMPLE FOR NOW -const unionSchema = object({ - foo: string(), - bar: discriminatedUnion("type", [ - { - type: "left", - foo: string(), - }, - { - type: "right", - bar: string(), - }, - ]), -}); From 1d3ba5e6f7f1cedb94a269a000fd39cd1e90e3af Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Wed, 24 Jun 2026 10:45:07 +0200 Subject: [PATCH 04/11] update --- .../next-example/src/components/dynz/form.tsx | 4 ++-- .../src/components/dynz/union-key.tsx | 11 +---------- .../conditions/get-condition-dependencies.ts | 5 ----- .../dynz/src/conditions/resolve-property.ts | 3 +-- packages/dynz/src/utils/get-nested.ts | 1 - .../src/hooks/use-conditional-property.ts | 18 +++++++++++------- 6 files changed, 15 insertions(+), 27 deletions(-) diff --git a/examples/next-example/src/components/dynz/form.tsx b/examples/next-example/src/components/dynz/form.tsx index 287da7f..d092f65 100644 --- a/examples/next-example/src/components/dynz/form.tsx +++ b/examples/next-example/src/components/dynz/form.tsx @@ -35,7 +35,7 @@ export function DynzForm, A extends SchemaValues { const customErrorMessagePath = `${name}.${error.path.slice(2)}.errors.${error.customCode}`; - console.log(error) + return t.has(customErrorMessagePath) ? t(customErrorMessagePath, error as unknown as Record) : t(`errors.${error.customCode}`, error as unknown as Record); @@ -45,7 +45,7 @@ export function DynzForm, A extends SchemaValues -

onSubmit?.(values), (err) => console.log(err))}> + onSubmit?.(values))}> {children}
diff --git a/examples/next-example/src/components/dynz/union-key.tsx b/examples/next-example/src/components/dynz/union-key.tsx index 174dc0f..90baad9 100644 --- a/examples/next-example/src/components/dynz/union-key.tsx +++ b/examples/next-example/src/components/dynz/union-key.tsx @@ -1,5 +1,4 @@ -import { useDiscriminatedUnionKeyValues, useDynzFormContext } from "@dynz/react-hook-form"; -import { type DiscriminatedUnionSchema, findSchemaByPath, type OptionsSchema, SchemaType } from "dynz"; +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"; @@ -9,17 +8,9 @@ export type DynzSelectProps = { }; export function DynzUnionKey({ name }: DynzSelectProps) { - const { watch } = useDynzFormContext(); - - const vals = watch() - console.log('val', vals) - - // const innerSchema = findSchemaByPath(`$.${name}`, schema, SchemaType.DISCRIMINATED_UNION); - // Get options from schema if not provided via props const options = useDiscriminatedUnionKeyValues(name); - console.log('options', options) return ( ( // 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) { - console.log("reslolveProperty === null: ", path); return false; } const ret = _resolveProperty(nested.schema, property, currentPath, defaultValue, context); - console.log("ret: ", path, ret); + if (ret === false) { return false; } diff --git a/packages/dynz/src/utils/get-nested.ts b/packages/dynz/src/utils/get-nested.ts index 3a66a28..06d990c 100644 --- a/packages/dynz/src/utils/get-nested.ts +++ b/packages/dynz/src/utils/get-nested.ts @@ -51,7 +51,6 @@ export function getNested( if (acc.schema.type === SchemaType.DISCRIMINATED_UNION) { // if the key is referenced return the schema of the union type - console.log("curssss", cur, acc.schema.key); if (cur === acc.schema.key) { return { value: isObject(acc.value) ? acc.value[acc.schema.key] : undefined, 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 0edd523..3eb49f7 100644 --- a/packages/react-hook-form/src/hooks/use-conditional-property.ts +++ b/packages/react-hook-form/src/hooks/use-conditional-property.ts @@ -15,15 +15,21 @@ function getPropertyDependencies( const ancestorPath = segments.slice(0, i + 1).join("."); const ancestor = findSchemaByPath(ancestorPath, schema); - if (ancestor.type === SchemaType.DISCRIMINATED_UNION) { - return [`${ancestorPath}.${ancestor.key}`.slice(2)]; - } + // 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)), + ]; }); } @@ -49,8 +55,6 @@ export function useConditionalProperty( // otherwise the hook never re-renders when the ancestor's controlling field changes. const dependencies = [...new Set(paths.flatMap((path) => getPropertyDependencies(path, property, schema)))]; - console.log("useConditionalProperty:", name, property, dependencies); - // Watch is just here to trigger a rerender when a value gets updated const watchedValues = useWatch({ name: dependencies, From 7d96ef7af2639913411aecffb8933aa76743dad8 Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Wed, 24 Jun 2026 10:47:14 +0200 Subject: [PATCH 05/11] update --- .../next-example/src/components/dynz/dynz-form-field.tsx | 8 ++------ examples/next-example/src/components/dynz/union-key.tsx | 1 - 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/examples/next-example/src/components/dynz/dynz-form-field.tsx b/examples/next-example/src/components/dynz/dynz-form-field.tsx index c3b9c55..0a4d1b6 100644 --- a/examples/next-example/src/components/dynz/dynz-form-field.tsx +++ b/examples/next-example/src/components/dynz/dynz-form-field.tsx @@ -18,7 +18,6 @@ type TranslationsObject = { type DynzFormFieldProps = { name: string; - rhfName?: string render: ({ field, translations, @@ -35,7 +34,7 @@ type DynzFormFieldProps = { }) => React.ReactElement; }; -export const DynzFormField = ({ name, rhfName, render }: DynzFormFieldProps) => { +export const DynzFormField = ({ name, render }: DynzFormFieldProps) => { const { control, getDependencies, name: i18nPath } = useDynzFormContext(); const t = useTranslations(); const isMutable = useIsMutable(name); @@ -59,7 +58,6 @@ export const DynzFormField = ({ name, rhfName, render }: DynzFormFieldProps) => return ( const DynzFormFieldInner = memo( ({ name, - rhfName, control, dependencies, readOnly, @@ -87,7 +84,6 @@ const DynzFormFieldInner = memo( render, }: { name: string; - rhfName?: string; control: Control; dependencies: string[] | undefined; readOnly: boolean; @@ -103,7 +99,7 @@ const DynzFormFieldInner = memo( return ( ( From 85f3fce97225c16eb96bfae02a88060401a0716e Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Wed, 24 Jun 2026 10:52:50 +0200 Subject: [PATCH 06/11] update --- packages/dynz/src/functions/resolve.ts | 5 +++++ packages/dynz/src/utils/find-schema-by-path.ts | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/packages/dynz/src/functions/resolve.ts b/packages/dynz/src/functions/resolve.ts index 1a86d76..3024c78 100644 --- a/packages/dynz/src/functions/resolve.ts +++ b/packages/dynz/src/functions/resolve.ts @@ -16,6 +16,11 @@ export function unpackRef( ...expected: T[] ): ValueType | undefined { const absolutePath = ensureAbsolutePath(ref.path, path); + + // 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) { diff --git a/packages/dynz/src/utils/find-schema-by-path.ts b/packages/dynz/src/utils/find-schema-by-path.ts index 3246c55..94f82db 100644 --- a/packages/dynz/src/utils/find-schema-by-path.ts +++ b/packages/dynz/src/utils/find-schema-by-path.ts @@ -30,7 +30,13 @@ export function findSchemaByPath(path: string, schema 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" || From fa4780ae756d42b488ad5a7023c1a2f72c6c80cb Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Wed, 24 Jun 2026 10:54:18 +0200 Subject: [PATCH 07/11] update --- pnpm-lock.yaml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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): From 5bce2b5ec64fe9a6cf01c4d4962435b829d6caea Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Wed, 24 Jun 2026 11:52:46 +0200 Subject: [PATCH 08/11] update --- examples/example/src/index.ts | 80 ++++++++++++------- .../app/[locale]/discriminated-union/page.tsx | 4 +- 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/examples/example/src/index.ts b/examples/example/src/index.ts index 658791d..2e56cf6 100644 --- a/examples/example/src/index.ts +++ b/examples/example/src/index.ts @@ -1,36 +1,57 @@ import * as d from "dynz"; - // runExample(); -const unionSchema = d.object({ - name: d.string(), - contactDetails: d.discriminatedUnion("type", [ - { - type: "email", - email: d.string(), - }, - { - type: "phone", - phone: d.string(), - }, - ]), -}); - -const fooIncluded = d.isIncluded(unionSchema, '$.contactDetails.type', { contactDetails: { type: "email", }, name: "da" }) -const fooFooIncluded = d.isIncluded(unionSchema, '$.contactDetails.email', { contactDetails: { type: "email", }, name: "fo" }) - -type Foo = d.SchemaValues - -console.log('fooIncluded:', fooIncluded, fooFooIncluded) -// async function foo() { -// const res = await d.validate(unionSchema, undefined, { -// foo: { -// type: 1, -// foo: 1 -// }, -// bar: 1 -// }) +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) @@ -45,7 +66,6 @@ console.log('fooIncluded:', fooIncluded, fooFooIncluded) // foo().then(() => console.log('damn..')) - // console.log(`stringSchema result:`, d.validate(stringSchema, undefined, { // foo: [{ // length: 3, 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 2e748ae..1fba3ec 100644 --- a/examples/next-example/src/app/[locale]/discriminated-union/page.tsx +++ b/examples/next-example/src/app/[locale]/discriminated-union/page.tsx @@ -28,8 +28,8 @@ export default function Home() { - - + + From e1dfdc45b31a004b63f144488cd8c87be43fb98f Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Tue, 7 Jul 2026 21:35:38 +0200 Subject: [PATCH 09/11] added to json schema library --- examples/example/package.json | 1 + packages/to-json-schema/eslint.config.mjs | 4 + packages/to-json-schema/package.json | 55 ++++ .../to-json-schema/src/convert-rules.test.ts | 172 ++++++++++++ packages/to-json-schema/src/convert-rules.ts | 255 ++++++++++++++++++ .../to-json-schema/src/convert-schema.test.ts | 152 +++++++++++ packages/to-json-schema/src/convert-schema.ts | 214 +++++++++++++++ packages/to-json-schema/src/index.ts | 2 + packages/to-json-schema/src/report-issue.ts | 15 ++ packages/to-json-schema/src/resolve-static.ts | 19 ++ .../src/to-standard-json-schema.test.ts | 48 ++++ .../src/to-standard-json-schema.ts | 15 ++ packages/to-json-schema/src/types.ts | 48 ++++ packages/to-json-schema/tsconfig.json | 8 + packages/to-json-schema/tsdown.config.ts | 23 ++ packages/to-json-schema/vitest.config.ts | 13 + pnpm-lock.yaml | 13 + 17 files changed, 1057 insertions(+) create mode 100644 packages/to-json-schema/eslint.config.mjs create mode 100644 packages/to-json-schema/package.json create mode 100644 packages/to-json-schema/src/convert-rules.test.ts create mode 100644 packages/to-json-schema/src/convert-rules.ts create mode 100644 packages/to-json-schema/src/convert-schema.test.ts create mode 100644 packages/to-json-schema/src/convert-schema.ts create mode 100644 packages/to-json-schema/src/index.ts create mode 100644 packages/to-json-schema/src/report-issue.ts create mode 100644 packages/to-json-schema/src/resolve-static.ts create mode 100644 packages/to-json-schema/src/to-standard-json-schema.test.ts create mode 100644 packages/to-json-schema/src/to-standard-json-schema.ts create mode 100644 packages/to-json-schema/src/types.ts create mode 100644 packages/to-json-schema/tsconfig.json create mode 100644 packages/to-json-schema/tsdown.config.ts create mode 100644 packages/to-json-schema/vitest.config.ts diff --git a/examples/example/package.json b/examples/example/package.json index 187328f..f0a8f4a 100644 --- a/examples/example/package.json +++ b/examples/example/package.json @@ -11,6 +11,7 @@ "lint": "eslint . --max-warnings 0" }, "dependencies": { + "@dynz/to-json-schema": "workspace:*", "dynz": "workspace:*", "nodemon": "^3.1.10", "ts-node": "^10.9.2", diff --git a/packages/to-json-schema/eslint.config.mjs b/packages/to-json-schema/eslint.config.mjs new file mode 100644 index 0000000..9b56f93 --- /dev/null +++ b/packages/to-json-schema/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@repo/eslint-config/base"; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/to-json-schema/package.json b/packages/to-json-schema/package.json new file mode 100644 index 0000000..f57cca5 --- /dev/null +++ b/packages/to-json-schema/package.json @@ -0,0 +1,55 @@ +{ + "name": "@dynz/to-json-schema", + "version": "0.0.1", + "private": false, + "license": "BUSL-1.1", + "type": "module", + "sideEffects": false, + "keywords": [ + "typescript", + "schema", + "validation", + "json-schema", + "form", + "type-safe" + ], + "repository": { + "type": "git", + "url": "https://github.com/rubenvanrooij/dynz" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "author": "Ruben van Rooij ", + "description": "Convert dynz schemas to standard JSON Schema.", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "check-types": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest run --watch" + }, + "peerDependencies": { + "dynz": "^1.0.0" + }, + "devDependencies": { + "tsdown": "^0.16.5" + } +} diff --git a/packages/to-json-schema/src/convert-rules.test.ts b/packages/to-json-schema/src/convert-rules.test.ts new file mode 100644 index 0000000..a5ac05a --- /dev/null +++ b/packages/to-json-schema/src/convert-rules.test.ts @@ -0,0 +1,172 @@ +import { ref, v } from "dynz"; +import { describe, expect, it, vi } from "vitest"; +import { applyRule } from "./convert-rules"; +import type { JsonSchema } from "./types"; + +describe("applyRule", () => { + it("maps min/max to minimum/maximum", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "min", min: v(5) }, "number", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "max", max: v(10) }, "number", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({ minimum: 5, maximum: 10 }); + }); + + it("maps min_length/max_length to minLength/maxLength", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "min_length", min: v(1) }, "string", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "max_length", max: v(50) }, "string", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({ minLength: 1, maxLength: 50 }); + }); + + it("maps min_length/max_length on an array schema to minItems/maxItems", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "min_length", min: v(1) }, "array", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "max_length", max: v(3) }, "array", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({ minItems: 1, maxItems: 3 }); + }); + + it("maps min_entries/max_entries (object key count) to minProperties/maxProperties", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "min_entries", min: v(1) }, "object", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "max_entries", max: v(3) }, "object", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({ minProperties: 1, maxProperties: 3 }); + }); + + it("maps max_precision to multipleOf", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "max_precision", maxPrecision: v(2) }, "number", { errorMode: "ignore" }); + + expect(jsonSchema.multipleOf).toBeCloseTo(0.01); + }); + + it("maps regex to pattern", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "regex", regex: "^[a-z]+$" }, "string", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({ pattern: "^[a-z]+$" }); + }); + + it("warns when regex flags are set but still emits the pattern", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "regex", regex: "^[a-z]+$", flags: "i" }, "string", { errorMode: "warn" }); + + expect(jsonSchema.pattern).toBe("^[a-z]+$"); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("maps email to format", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "email" }, "string", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({ format: "email" }); + }); + + it("maps is_numeric to a numeric pattern", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "is_numeric" }, "string", { errorMode: "ignore" }); + + expect(jsonSchema.pattern).toBe("^[+-]?\\d+(\\.\\d+)?$"); + }); + + it("maps equals to const", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "equals", equals: v("admin") }, "string", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({ const: "admin" }); + }); + + it("maps includes on a string schema to pattern", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "includes", includes: v("foo") }, "string", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({ pattern: "foo" }); + }); + + it("maps includes on an array schema to contains", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "includes", includes: v("admin") }, "array", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({ contains: { const: "admin" } }); + }); + + it("maps not_includes to a negated pattern/contains", () => { + const stringSchema: JsonSchema = {}; + applyRule(stringSchema, { type: "not_includes", notIncludes: v("foo") }, "string", { errorMode: "ignore" }); + expect(stringSchema).toEqual({ not: { pattern: "foo" } }); + + const arraySchema: JsonSchema = {}; + applyRule(arraySchema, { type: "not_includes", notIncludes: v("admin") }, "array", { errorMode: "ignore" }); + expect(arraySchema).toEqual({ not: { contains: { const: "admin" } } }); + }); + + it("maps one_of to enum when every value is static", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "one_of", values: [v("a"), v("b")] }, "string", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({ enum: ["a", "b"] }); + }); + + it("skips one_of entirely when any value is not static", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "one_of", values: [v("a"), ref("other")] }, "string", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({}); + }); + + it("maps not_one_of to not/enum", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "not_one_of", values: [v(1), v(2)] }, "number", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({ not: { enum: [1, 2] } }); + }); + + it("maps a single static mime_type to contentMediaType", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "mime_type", mimeType: v("image/png") }, "file", { errorMode: "ignore" }); + + expect(jsonSchema).toEqual({ contentMediaType: "image/png" }); + }); + + it("skips mime_type with multiple values", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "mime_type", mimeType: v(["image/png", "image/jpeg"]) }, "file", { + errorMode: "ignore", + }); + + expect(jsonSchema).toEqual({}); + }); + + it("skips rules with no JSON Schema equivalent", () => { + for (const rule of [ + { type: "min_date" as const, min: v(new Date()) }, + { type: "max_date" as const, max: v(new Date()) }, + { type: "before" as const, before: v(new Date()) }, + { type: "after" as const, after: v(new Date()) }, + { type: "min_size" as const, min: v(1) }, + { type: "max_size" as const, max: v(1) }, + { type: "custom" as const, name: "foo", params: {} }, + { type: "conditional" as const, cases: [] }, + ]) { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, rule, "string", { errorMode: "ignore" }); + expect(jsonSchema).toEqual({}); + } + }); + + it("throws when errorMode is 'throw' and a rule value is not static", () => { + expect(() => applyRule({}, { type: "min", min: ref("other") }, "number", { errorMode: "throw" })).toThrow(); + }); + + it("does not throw or warn when errorMode is 'ignore'", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + expect(() => applyRule({}, { type: "min", min: ref("other") }, "number", { errorMode: "ignore" })).not.toThrow(); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/packages/to-json-schema/src/convert-rules.ts b/packages/to-json-schema/src/convert-rules.ts new file mode 100644 index 0000000..a71f22f --- /dev/null +++ b/packages/to-json-schema/src/convert-rules.ts @@ -0,0 +1,255 @@ +import type { Rule, SchemaType } from "dynz"; +import { reportIssue } from "./report-issue"; +import { resolveStatic } from "./resolve-static"; +import type { ConversionContext, JsonSchema } from "./types"; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Applies a dynz rule to a JSON Schema in place. Rules whose value can't be + * statically resolved (references, predicates, transformers) or that have + * no JSON Schema equivalent are skipped and reported via `reportIssue`. + */ +export function applyRule( + jsonSchema: JsonSchema, + rule: Rule, + schemaType: SchemaType, + context: ConversionContext +): void { + switch (rule.type) { + case "min": { + const min = resolveStatic(rule.min); + if (min.ok) { + jsonSchema.minimum = min.value as number; + } else { + reportIssue(context, `"min" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "max": { + const max = resolveStatic(rule.max); + if (max.ok) { + jsonSchema.maximum = max.value as number; + } else { + reportIssue(context, `"max" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "min_length": { + // dynz reuses "min_length" for both string length and array length (via .min() on + // either schema kind), so the JSON Schema keyword depends on which kind this is. + const min = resolveStatic(rule.min); + if (min.ok) { + if (schemaType === "array") { + jsonSchema.minItems = min.value as number; + } else { + jsonSchema.minLength = min.value as number; + } + } else { + reportIssue(context, `"min_length" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "max_length": { + const max = resolveStatic(rule.max); + if (max.ok) { + if (schemaType === "array") { + jsonSchema.maxItems = max.value as number; + } else { + jsonSchema.maxLength = max.value as number; + } + } else { + reportIssue(context, `"max_length" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "min_entries": { + // "min_entries"/"max_entries" are only produced by object schemas (.minEntries()), for + // the number of keys — i.e. JSON Schema's "minProperties"/"maxProperties". + const min = resolveStatic(rule.min); + if (min.ok) { + jsonSchema.minProperties = min.value as number; + } else { + reportIssue(context, `"min_entries" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "max_entries": { + const max = resolveStatic(rule.max); + if (max.ok) { + jsonSchema.maxProperties = max.value as number; + } else { + reportIssue(context, `"max_entries" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "max_precision": { + const maxPrecision = resolveStatic(rule.maxPrecision); + if (maxPrecision.ok) { + jsonSchema.multipleOf = 1 / 10 ** (maxPrecision.value as number); + } else { + reportIssue(context, `"max_precision" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "regex": { + jsonSchema.pattern = rule.regex; + if (rule.flags) { + reportIssue( + context, + `"regex" rule flags ("${rule.flags}") cannot be represented in JSON Schema's "pattern" keyword and were ignored.` + ); + } + break; + } + + case "email": { + jsonSchema.format = "email"; + break; + } + + case "is_numeric": { + // Best-effort approximation: JSON Schema has no dedicated "numeric string" keyword. + jsonSchema.pattern = "^[+-]?\\d+(\\.\\d+)?$"; + break; + } + + case "equals": { + const equals = resolveStatic(rule.equals); + if (equals.ok) { + jsonSchema.const = equals.value; + } else { + reportIssue(context, `"equals" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "includes": { + const includes = resolveStatic(rule.includes); + if (!includes.ok) { + reportIssue(context, `"includes" rule with a non-static value cannot be converted to JSON Schema.`); + break; + } + + if (schemaType === "array") { + jsonSchema.contains = { const: includes.value }; + } else { + jsonSchema.pattern = escapeRegExp(String(includes.value)); + } + break; + } + + case "not_includes": { + const notIncludes = resolveStatic(rule.notIncludes); + if (!notIncludes.ok) { + reportIssue(context, `"not_includes" rule with a non-static value cannot be converted to JSON Schema.`); + break; + } + + jsonSchema.not = + schemaType === "array" + ? { contains: { const: notIncludes.value } } + : { pattern: escapeRegExp(String(notIncludes.value)) }; + break; + } + + case "one_of": { + const values: unknown[] = []; + for (const value of rule.values) { + const resolved = resolveStatic(value); + if (!resolved.ok) { + reportIssue(context, `"one_of" rule with a non-static value cannot be converted to JSON Schema.`); + return; + } + values.push(resolved.value); + } + jsonSchema.enum = values; + break; + } + + case "not_one_of": { + const values: unknown[] = []; + for (const value of rule.values) { + const resolved = resolveStatic(value); + if (!resolved.ok) { + reportIssue(context, `"not_one_of" rule with a non-static value cannot be converted to JSON Schema.`); + return; + } + values.push(resolved.value); + } + jsonSchema.not = { enum: values }; + break; + } + + case "mime_type": { + const mimeType = resolveStatic(rule.mimeType); + if (!mimeType.ok) { + reportIssue(context, `"mime_type" rule with a non-static value cannot be converted to JSON Schema.`); + break; + } + + if (typeof mimeType.value === "string") { + jsonSchema.contentMediaType = mimeType.value; + } else if (Array.isArray(mimeType.value) && mimeType.value.length === 1) { + jsonSchema.contentMediaType = mimeType.value[0]; + } else { + reportIssue(context, `"mime_type" rule with multiple mime types has no JSON Schema equivalent.`); + } + break; + } + + case "min_date": + case "max_date": + case "before": + case "after": { + reportIssue( + context, + `"${rule.type}" rule has no JSON Schema equivalent (date comparisons aren't representable).` + ); + break; + } + + case "min_size": + case "max_size": { + reportIssue( + context, + `"${rule.type}" rule has no JSON Schema equivalent (byte-size constraints aren't representable).` + ); + break; + } + + case "custom": { + reportIssue(context, `"custom" rule "${rule.name}" has no JSON Schema equivalent.`); + break; + } + + case "conditional": { + reportIssue(context, `"conditional" rule has no JSON Schema equivalent.`); + break; + } + + default: { + reportIssue(context, `Unknown rule cannot be converted to JSON Schema.`); + } + } +} + +export function applyRules( + jsonSchema: JsonSchema, + rules: Rule[] | undefined, + schemaType: SchemaType, + context: ConversionContext +): void { + for (const rule of rules ?? []) { + applyRule(jsonSchema, rule, schemaType, context); + } +} diff --git a/packages/to-json-schema/src/convert-schema.test.ts b/packages/to-json-schema/src/convert-schema.test.ts new file mode 100644 index 0000000..4547493 --- /dev/null +++ b/packages/to-json-schema/src/convert-schema.test.ts @@ -0,0 +1,152 @@ +import { array, boolean, date, eq, file, literal, number, object, ref, string, v } from "dynz"; +import type { Schema } from "dynz"; +import { describe, expect, it, vi } from "vitest"; +import { convertSchema } from "./convert-schema"; +import type { ConversionContext } from "./types"; + +const ctx: ConversionContext = { errorMode: "ignore" }; + +describe("convertSchema", () => { + it("converts primitive schemas", () => { + expect(convertSchema(string(), ctx)).toEqual({ type: "string" }); + expect(convertSchema(number(), ctx)).toEqual({ type: "number" }); + expect(convertSchema(boolean(), ctx)).toEqual({ type: "boolean" }); + expect(convertSchema(date(), ctx)).toEqual({ type: "string", format: "date-time" }); + }); + + it("converts a literal schema, including null", () => { + expect(convertSchema(literal("admin"), ctx)).toEqual({ const: "admin" }); + expect(convertSchema(literal(null), ctx)).toEqual({ const: null }); + }); + + it("converts an enum schema", () => { + const schema: Schema = { type: "enum", enum: { A: "a", B: "b" } }; + + expect(convertSchema(schema, ctx)).toEqual({ type: "string", enum: ["a", "b"] }); + }); + + it("converts an options schema, honoring statically enabled/disabled entries", () => { + const schema: Schema = { + type: "options", + options: ["a", { enabled: true, value: "b" }, { enabled: false, value: "c" }], + }; + + expect(convertSchema(schema, ctx)).toEqual({ type: "string", enum: ["a", "b"] }); + }); + + it("keeps a predicate-enabled option and warns", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const schema: Schema = { + type: "options", + options: [{ enabled: eq(ref("type"), v("x")), value: "d" }], + }; + + expect(convertSchema(schema, { errorMode: "warn" })).toEqual({ type: "string", enum: ["d"] }); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("converts a file schema with a static mime_type rule", () => { + const schema = file().mimeType(v("image/png")); + + expect(convertSchema(schema, ctx)).toEqual({ type: "string", contentMediaType: "image/png" }); + }); + + it("converts an expression schema to an unconstrained schema", () => { + const schema: Schema = { type: "expression", value: v(1) }; + + expect(convertSchema(schema, ctx)).toEqual({}); + }); + + it("converts an array schema, applying items and entry-count rules", () => { + const schema = array(string()).min(1).max(3); + + expect(convertSchema(schema, ctx)).toEqual({ + type: "array", + items: { type: "string" }, + minItems: 1, + maxItems: 3, + }); + }); + + it("converts an object schema, marking only statically-mandatory fields as required", () => { + const schema = object({ + name: string(), + nickname: string().optional(), + role: string().setRequired(eq(ref("type"), v("admin"))), + hidden: string().setIncluded(false), + }); + + expect(convertSchema(schema, ctx)).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + nickname: { type: "string" }, + role: { type: "string" }, + hidden: { type: "string" }, + }, + required: ["name"], + }); + }); + + it("converts a discriminated union to oneOf, dropping non-schema sibling entries", () => { + // Built as a plain schema literal: the discriminatedUnion() fluent builder's CheckMember + // constraint statically forbids non-schema sibling fields, but the underlying + // DiscriminatedUnionSchema type still allows them (e.g. for dynamically constructed schemas). + const schema: Schema = { + type: "discriminated_union", + key: "kind", + schemas: [ + { kind: "a", value: string() }, + { kind: "b", value: number(), meta: "ignored" }, + ], + }; + + expect(convertSchema(schema, ctx)).toEqual({ + oneOf: [ + { + type: "object", + properties: { kind: { const: "a" }, value: { type: "string" } }, + required: ["kind", "value"], + }, + { + type: "object", + properties: { kind: { const: "b" }, value: { type: "number" } }, + required: ["kind", "value"], + }, + ], + }); + }); + + it("wraps private fields in the plain/masked shape", () => { + const schema = string().setPrivate(true); + + expect(convertSchema(schema, ctx)).toEqual({ + oneOf: [ + { + type: "object", + properties: { state: { const: "plain" }, value: { type: "string" } }, + required: ["state"], + }, + { + type: "object", + properties: { state: { const: "masked" }, value: { type: "string" } }, + required: ["state", "value"], + }, + ], + }); + }); + + it("carries over a static default value", () => { + expect(convertSchema(string().setDefault("hi"), ctx)).toEqual({ type: "string", default: "hi" }); + }); + + it("serializes a Date default to an ISO string", () => { + const defaultDate = new Date("2024-01-01T00:00:00.000Z"); + expect(convertSchema(date().setDefault(defaultDate), ctx)).toEqual({ + type: "string", + format: "date-time", + default: defaultDate.toISOString(), + }); + }); +}); diff --git a/packages/to-json-schema/src/convert-schema.ts b/packages/to-json-schema/src/convert-schema.ts new file mode 100644 index 0000000..4bbe06b --- /dev/null +++ b/packages/to-json-schema/src/convert-schema.ts @@ -0,0 +1,214 @@ +import { type OptionsValue, type OptionValue, type Schema, SchemaType } from "dynz"; +import { applyRules } from "./convert-rules"; +import { reportIssue } from "./report-issue"; +import type { ConversionContext, JsonSchema } from "./types"; + +function isMandatory(schema: Schema): boolean { + const isIncluded = schema.included === undefined || schema.included === true; + const isRequired = schema.required === undefined || schema.required === true; + return isIncluded && isRequired; +} + +function jsonTypeOf(value: unknown): string { + return typeof value; +} + +function applyEnumValues(jsonSchema: JsonSchema, values: unknown[]): void { + jsonSchema.enum = values; + const types = Array.from(new Set(values.map(jsonTypeOf))); + if (types.length === 1) { + jsonSchema.type = types[0]; + } else if (types.length > 1) { + jsonSchema.type = types; + } +} + +function collectOptionValues(options: OptionsValue, context: ConversionContext): unknown[] { + const values: unknown[] = []; + + const visit = (option: OptionValue): void => { + if (typeof option !== "object") { + values.push(option); + return; + } + + if (option.enabled === false) { + return; + } + + if (typeof option.enabled === "object") { + reportIssue( + context, + `An "options" entry has a predicate-based "enabled" flag; whether it's enabled can't be determined statically, so it was included in the JSON Schema "enum" anyway.` + ); + } + + visit(option.value); + }; + + for (const option of options) { + visit(option); + } + + return values; +} + +function isSchema(value: Schema | string | number | boolean): value is Schema { + return typeof value === "object" && value !== null && "type" in value; +} + +function applyPrivacyWrapper(schema: Schema, innerSchema: JsonSchema): JsonSchema { + if (schema.private !== true) { + return innerSchema; + } + + return { + oneOf: [ + { + type: "object", + properties: { state: { const: "plain" }, value: innerSchema }, + required: ["state"], + }, + { + type: "object", + properties: { state: { const: "masked" }, value: { type: "string" } }, + required: ["state", "value"], + }, + ], + }; +} + +function applyDefault(schema: Schema, jsonSchema: JsonSchema): void { + if (schema.default === undefined) { + return; + } + + jsonSchema.default = schema.default instanceof Date ? schema.default.toISOString() : schema.default; +} + +/** + * Converts a dynz schema to a JSON Schema (2020-12) document. Rule values + * and schema kinds without a JSON Schema equivalent are handled per + * `context.errorMode`. + */ +export function convertSchema(schema: Schema, context: ConversionContext): JsonSchema { + const jsonSchema = convertSchemaKind(schema, context); + applyDefault(schema, jsonSchema); + return applyPrivacyWrapper(schema, jsonSchema); +} + +function convertSchemaKind(schema: Schema, context: ConversionContext): JsonSchema { + switch (schema.type) { + case SchemaType.STRING: { + const jsonSchema: JsonSchema = { type: "string" }; + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.NUMBER: { + const jsonSchema: JsonSchema = { type: "number" }; + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.BOOLEAN: { + const jsonSchema: JsonSchema = { type: "boolean" }; + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.DATE: { + // JSON Schema has no native date type; represented as an ISO 8601 string. + const jsonSchema: JsonSchema = { type: "string", format: "date-time" }; + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.LITERAL: { + return { const: schema.value }; + } + + case SchemaType.ENUM: { + const jsonSchema: JsonSchema = {}; + applyEnumValues(jsonSchema, Object.values(schema.enum)); + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.OPTIONS: { + const jsonSchema: JsonSchema = {}; + applyEnumValues(jsonSchema, collectOptionValues(schema.options, context)); + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.FILE: { + // JSON Schema has no native file type; a "mime_type" rule (if static) adds contentMediaType. + const jsonSchema: JsonSchema = { type: "string" }; + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.EXPRESSION: { + // Expression values are computed at runtime; their type can't be known ahead of time. + return {}; + } + + case SchemaType.ARRAY: { + const jsonSchema: JsonSchema = { + type: "array", + items: convertSchema(schema.schema, context), + }; + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.OBJECT: { + const properties: Record = {}; + const required: string[] = []; + + for (const [key, fieldSchema] of Object.entries(schema.fields)) { + properties[key] = convertSchema(fieldSchema, context); + if (isMandatory(fieldSchema)) { + required.push(key); + } + } + + const jsonSchema: JsonSchema = { type: "object", properties }; + if (required.length > 0) { + jsonSchema.required = required; + } + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.DISCRIMINATED_UNION: { + const members = schema.schemas.map((member) => { + const properties: Record = { + [schema.key]: { const: member[schema.key] }, + }; + const required: string[] = [schema.key]; + + for (const [key, value] of Object.entries(member)) { + if (key === schema.key || !isSchema(value)) { + continue; + } + + properties[key] = convertSchema(value, context); + if (isMandatory(value)) { + required.push(key); + } + } + + return { type: "object", properties, required } satisfies JsonSchema; + }); + + return { oneOf: members }; + } + + default: { + reportIssue(context, `Unknown schema type cannot be converted to JSON Schema.`); + return {}; + } + } +} diff --git a/packages/to-json-schema/src/index.ts b/packages/to-json-schema/src/index.ts new file mode 100644 index 0000000..f4b1b94 --- /dev/null +++ b/packages/to-json-schema/src/index.ts @@ -0,0 +1,2 @@ +export { toStandardJsonSchema } from "./to-standard-json-schema"; +export type { ConversionConfig, ErrorMode, JsonSchema } from "./types"; diff --git a/packages/to-json-schema/src/report-issue.ts b/packages/to-json-schema/src/report-issue.ts new file mode 100644 index 0000000..8733976 --- /dev/null +++ b/packages/to-json-schema/src/report-issue.ts @@ -0,0 +1,15 @@ +import type { ConversionContext } from "./types"; + +/** + * Reports an unsupported or unresolvable conversion according to the + * configured error mode: throws, warns via `console.warn`, or is a no-op. + */ +export function reportIssue(context: ConversionContext, message: string): void { + if (context.errorMode === "throw") { + throw new Error(message); + } + + if (context.errorMode === "warn") { + console.warn(`[@dynz/to-json-schema] ${message}`); + } +} diff --git a/packages/to-json-schema/src/resolve-static.ts b/packages/to-json-schema/src/resolve-static.ts new file mode 100644 index 0000000..1cf76ac --- /dev/null +++ b/packages/to-json-schema/src/resolve-static.ts @@ -0,0 +1,19 @@ +import type { ParamaterValue, ValueType } from "dynz"; + +export type StaticResolution = { ok: true; value: T } | { ok: false }; + +/** + * Attempts to extract a literal value from a rule parameter. Only values + * wrapped with `v()`/`val()`/`st()` (i.e. `{ type: "st" }`) are statically + * known — references, predicates and transformers depend on runtime data + * and cannot be represented in a JSON Schema document. + */ +export function resolveStatic( + value: ParamaterValue | undefined +): StaticResolution { + if (value !== undefined && typeof value === "object" && "type" in value && value.type === "st") { + return { ok: true, value: value.value }; + } + + return { ok: false }; +} diff --git a/packages/to-json-schema/src/to-standard-json-schema.test.ts b/packages/to-json-schema/src/to-standard-json-schema.test.ts new file mode 100644 index 0000000..2830300 --- /dev/null +++ b/packages/to-json-schema/src/to-standard-json-schema.test.ts @@ -0,0 +1,48 @@ +import { array, eq, number, object, ref, string, v } from "dynz"; +import { describe, expect, it, vi } from "vitest"; +import { toStandardJsonSchema } from "./to-standard-json-schema"; + +describe("toStandardJsonSchema", () => { + it("converts a realistic object schema end-to-end", () => { + const schema = object({ + email: string().email(), + age: number().min(0).max(150).optional(), + role: string().oneOf([v("admin"), v("member")]), + promoCode: string().setRequired(eq(ref("role"), v("admin"))), + tags: array(string()).min(0), + }); + + expect(toStandardJsonSchema(schema)).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + email: { type: "string", format: "email" }, + age: { type: "number", minimum: 0, maximum: 150 }, + role: { type: "string", enum: ["admin", "member"] }, + promoCode: { type: "string" }, + tags: { type: "array", items: { type: "string" }, minItems: 0 }, + }, + // "age" is optional, "promoCode" is only conditionally required (a Predicate), + // so neither is statically mandatory. + required: ["email", "role", "tags"], + }); + }); + + it("defaults to errorMode 'warn' for unresolvable rule values", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const schema = string().min(ref("minLength")); + + expect(toStandardJsonSchema(schema)).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "string", + }); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("throws when errorMode is 'throw' and a rule value is unresolvable", () => { + const schema = string().min(ref("minLength")); + + expect(() => toStandardJsonSchema(schema, { errorMode: "throw" })).toThrow(); + }); +}); diff --git a/packages/to-json-schema/src/to-standard-json-schema.ts b/packages/to-json-schema/src/to-standard-json-schema.ts new file mode 100644 index 0000000..d3851b4 --- /dev/null +++ b/packages/to-json-schema/src/to-standard-json-schema.ts @@ -0,0 +1,15 @@ +import type { Schema } from "dynz"; +import { convertSchema } from "./convert-schema"; +import type { ConversionConfig, JsonSchema } from "./types"; + +/** + * Converts a dynz schema to a standard JSON Schema (2020-12) document. + * + * Rule values that depend on runtime data (references, predicates, + * transformers) and schema kinds without a JSON Schema equivalent are + * handled according to `config.errorMode` (default `"warn"`). + */ +export function toStandardJsonSchema(schema: T, config: ConversionConfig = {}): JsonSchema { + const jsonSchema = convertSchema(schema, { errorMode: config.errorMode ?? "warn" }); + return { $schema: "https://json-schema.org/draft/2020-12/schema", ...jsonSchema }; +} diff --git a/packages/to-json-schema/src/types.ts b/packages/to-json-schema/src/types.ts new file mode 100644 index 0000000..6266e21 --- /dev/null +++ b/packages/to-json-schema/src/types.ts @@ -0,0 +1,48 @@ +/** + * A JSON Schema (2020-12) document. Loosely typed since the shape varies + * significantly by keyword combination. + */ +export interface JsonSchema { + $schema?: string; + type?: string | string[]; + properties?: Record; + required?: string[]; + items?: JsonSchema; + enum?: unknown[]; + const?: unknown; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + minItems?: number; + maxItems?: number; + minProperties?: number; + maxProperties?: number; + pattern?: string; + format?: string; + multipleOf?: number; + contentMediaType?: string; + contains?: JsonSchema; + not?: JsonSchema; + oneOf?: JsonSchema[]; + default?: unknown; + [key: string]: unknown; +} + +/** + * The policy for handling schema kinds/rule values that cannot be + * represented in JSON Schema. + */ +export type ErrorMode = "throw" | "warn" | "ignore"; + +export interface ConversionConfig { + /** + * Policy for handling unsupported/unresolvable rule values and schema + * kinds. Defaults to `"warn"`. + */ + errorMode?: ErrorMode; +} + +export interface ConversionContext { + readonly errorMode: ErrorMode; +} diff --git a/packages/to-json-schema/tsconfig.json b/packages/to-json-schema/tsconfig.json new file mode 100644 index 0000000..f5e69af --- /dev/null +++ b/packages/to-json-schema/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../.configs/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/to-json-schema/tsdown.config.ts b/packages/to-json-schema/tsdown.config.ts new file mode 100644 index 0000000..9402bc5 --- /dev/null +++ b/packages/to-json-schema/tsdown.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig([ + { + entry: ["./src/index.ts"], + clean: true, + format: ["esm", "cjs"], + minify: false, + dts: true, + outDir: "./dist", + }, + { + entry: ["./src/index.ts"], + clean: true, + format: ["esm", "cjs"], + minify: true, + dts: false, + outDir: "./dist", + outExtensions: ({ format }) => ({ + js: format === "cjs" ? ".min.cjs" : ".min.js", + }), + }, +]); diff --git a/packages/to-json-schema/vitest.config.ts b/packages/to-json-schema/vitest.config.ts new file mode 100644 index 0000000..2a9db48 --- /dev/null +++ b/packages/to-json-schema/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import rootConfig from "../../vitest.config.ts"; + +export default mergeConfig( + rootConfig, + defineProject({ + test: { + typecheck: { + tsconfig: "./tsconfig.json", + }, + }, + }) +) as object; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae00722..142cea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: examples/example: dependencies: + '@dynz/to-json-schema': + specifier: workspace:* + version: link:../../packages/to-json-schema dynz: specifier: workspace:* version: link:../../packages/dynz @@ -220,6 +223,16 @@ importers: specifier: ^0.16.5 version: 0.16.5(ms@2.1.3)(synckit@0.11.11)(typescript@5.9.2) + packages/to-json-schema: + dependencies: + dynz: + specifier: ^1.0.0 + version: link:../dynz + devDependencies: + tsdown: + specifier: ^0.16.5 + version: 0.16.5(ms@2.1.3)(synckit@0.11.11)(typescript@5.9.2) + packages: '@alloc/quick-lru@5.2.0': From a59a1e1f5ef4fa886c37ec94fd721d274269b2fd Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Tue, 7 Jul 2026 22:04:57 +0200 Subject: [PATCH 10/11] added json schema library to pipeline --- .github/actions/environment/action.yml | 3 ++- .github/workflows/ci.yml | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/environment/action.yml b/.github/actions/environment/action.yml index 0baa98c..b60e557 100644 --- a/.github/actions/environment/action.yml +++ b/.github/actions/environment/action.yml @@ -26,7 +26,8 @@ runs: with: path: | ${{ github.workspace }}/packages/dynz/dist - ${{ github.workspace }}/packages/react-hook-form-resolver/dist + ${{ github.workspace }}/packages/react-hook-form/dist + ${{ github.workspace }}/packages/to-json-schema/dist key: library-build-cache-${{ hashFiles('packages/**/*.ts', '!packages/**/*.test.ts') }} - name: Build packages diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f775b59..5f64bf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,6 @@ on: branches: [main] jobs: - setup_environment: name: Install packages runs-on: ubuntu-latest @@ -15,7 +14,7 @@ jobs: uses: actions/checkout@v4 - name: Setup environment uses: ./.github/actions/environment - + library_preview: name: Publish library via pkg.pr.new runs-on: ubuntu-latest @@ -26,7 +25,7 @@ jobs: - name: Setup environment uses: ./.github/actions/environment - name: Publish dynz library - run: pnpx pkg-pr-new publish --comment=update --pnpm './packages/dynz' './packages/react-hook-form' + run: pnpx pkg-pr-new publish --comment=update --pnpm './packages/dynz' './packages/react-hook-form' './packages/to-json-schema' format_check: name: Run format in library @@ -39,7 +38,7 @@ jobs: uses: ./.github/actions/environment - name: Format check run: pnpm format:check - + lint: name: Run lint in library needs: setup_environment From 39a47d241af28db076138a63a946851ac8cd80bf Mon Sep 17 00:00:00 2001 From: "Ruben v. Rooij" Date: Wed, 8 Jul 2026 22:06:25 +0200 Subject: [PATCH 11/11] added option to generate input and output schemas --- .../to-json-schema/src/convert-rules.test.ts | 79 +++++++++++++------ .../to-json-schema/src/convert-schema.test.ts | 51 +++++++++++- packages/to-json-schema/src/convert-schema.ts | 12 ++- .../src/to-standard-json-schema.test.ts | 28 +++++++ .../src/to-standard-json-schema.ts | 9 ++- packages/to-json-schema/src/types.ts | 14 ++++ 6 files changed, 163 insertions(+), 30 deletions(-) diff --git a/packages/to-json-schema/src/convert-rules.test.ts b/packages/to-json-schema/src/convert-rules.test.ts index a5ac05a..f421930 100644 --- a/packages/to-json-schema/src/convert-rules.test.ts +++ b/packages/to-json-schema/src/convert-rules.test.ts @@ -6,46 +6,49 @@ import type { JsonSchema } from "./types"; describe("applyRule", () => { it("maps min/max to minimum/maximum", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "min", min: v(5) }, "number", { errorMode: "ignore" }); - applyRule(jsonSchema, { type: "max", max: v(10) }, "number", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "min", min: v(5) }, "number", { errorMode: "ignore", mode: "input" }); + applyRule(jsonSchema, { type: "max", max: v(10) }, "number", { errorMode: "ignore", mode: "input" }); expect(jsonSchema).toEqual({ minimum: 5, maximum: 10 }); }); it("maps min_length/max_length to minLength/maxLength", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "min_length", min: v(1) }, "string", { errorMode: "ignore" }); - applyRule(jsonSchema, { type: "max_length", max: v(50) }, "string", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "min_length", min: v(1) }, "string", { errorMode: "ignore", mode: "input" }); + applyRule(jsonSchema, { type: "max_length", max: v(50) }, "string", { errorMode: "ignore", mode: "input" }); expect(jsonSchema).toEqual({ minLength: 1, maxLength: 50 }); }); it("maps min_length/max_length on an array schema to minItems/maxItems", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "min_length", min: v(1) }, "array", { errorMode: "ignore" }); - applyRule(jsonSchema, { type: "max_length", max: v(3) }, "array", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "min_length", min: v(1) }, "array", { errorMode: "ignore", mode: "input" }); + applyRule(jsonSchema, { type: "max_length", max: v(3) }, "array", { errorMode: "ignore", mode: "input" }); expect(jsonSchema).toEqual({ minItems: 1, maxItems: 3 }); }); it("maps min_entries/max_entries (object key count) to minProperties/maxProperties", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "min_entries", min: v(1) }, "object", { errorMode: "ignore" }); - applyRule(jsonSchema, { type: "max_entries", max: v(3) }, "object", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "min_entries", min: v(1) }, "object", { errorMode: "ignore", mode: "input" }); + applyRule(jsonSchema, { type: "max_entries", max: v(3) }, "object", { errorMode: "ignore", mode: "input" }); expect(jsonSchema).toEqual({ minProperties: 1, maxProperties: 3 }); }); it("maps max_precision to multipleOf", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "max_precision", maxPrecision: v(2) }, "number", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "max_precision", maxPrecision: v(2) }, "number", { + errorMode: "ignore", + mode: "input", + }); expect(jsonSchema.multipleOf).toBeCloseTo(0.01); }); it("maps regex to pattern", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "regex", regex: "^[a-z]+$" }, "string", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "regex", regex: "^[a-z]+$" }, "string", { errorMode: "ignore", mode: "input" }); expect(jsonSchema).toEqual({ pattern: "^[a-z]+$" }); }); @@ -53,7 +56,10 @@ describe("applyRule", () => { it("warns when regex flags are set but still emits the pattern", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "regex", regex: "^[a-z]+$", flags: "i" }, "string", { errorMode: "warn" }); + applyRule(jsonSchema, { type: "regex", regex: "^[a-z]+$", flags: "i" }, "string", { + errorMode: "warn", + mode: "input", + }); expect(jsonSchema.pattern).toBe("^[a-z]+$"); expect(warn).toHaveBeenCalled(); @@ -62,73 +68,91 @@ describe("applyRule", () => { it("maps email to format", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "email" }, "string", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "email" }, "string", { errorMode: "ignore", mode: "input" }); expect(jsonSchema).toEqual({ format: "email" }); }); it("maps is_numeric to a numeric pattern", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "is_numeric" }, "string", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "is_numeric" }, "string", { errorMode: "ignore", mode: "input" }); expect(jsonSchema.pattern).toBe("^[+-]?\\d+(\\.\\d+)?$"); }); it("maps equals to const", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "equals", equals: v("admin") }, "string", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "equals", equals: v("admin") }, "string", { errorMode: "ignore", mode: "input" }); expect(jsonSchema).toEqual({ const: "admin" }); }); it("maps includes on a string schema to pattern", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "includes", includes: v("foo") }, "string", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "includes", includes: v("foo") }, "string", { errorMode: "ignore", mode: "input" }); expect(jsonSchema).toEqual({ pattern: "foo" }); }); it("maps includes on an array schema to contains", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "includes", includes: v("admin") }, "array", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "includes", includes: v("admin") }, "array", { errorMode: "ignore", mode: "input" }); expect(jsonSchema).toEqual({ contains: { const: "admin" } }); }); it("maps not_includes to a negated pattern/contains", () => { const stringSchema: JsonSchema = {}; - applyRule(stringSchema, { type: "not_includes", notIncludes: v("foo") }, "string", { errorMode: "ignore" }); + applyRule(stringSchema, { type: "not_includes", notIncludes: v("foo") }, "string", { + errorMode: "ignore", + mode: "input", + }); expect(stringSchema).toEqual({ not: { pattern: "foo" } }); const arraySchema: JsonSchema = {}; - applyRule(arraySchema, { type: "not_includes", notIncludes: v("admin") }, "array", { errorMode: "ignore" }); + applyRule(arraySchema, { type: "not_includes", notIncludes: v("admin") }, "array", { + errorMode: "ignore", + mode: "input", + }); expect(arraySchema).toEqual({ not: { contains: { const: "admin" } } }); }); it("maps one_of to enum when every value is static", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "one_of", values: [v("a"), v("b")] }, "string", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "one_of", values: [v("a"), v("b")] }, "string", { + errorMode: "ignore", + mode: "input", + }); expect(jsonSchema).toEqual({ enum: ["a", "b"] }); }); it("skips one_of entirely when any value is not static", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "one_of", values: [v("a"), ref("other")] }, "string", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "one_of", values: [v("a"), ref("other")] }, "string", { + errorMode: "ignore", + mode: "input", + }); expect(jsonSchema).toEqual({}); }); it("maps not_one_of to not/enum", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "not_one_of", values: [v(1), v(2)] }, "number", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "not_one_of", values: [v(1), v(2)] }, "number", { + errorMode: "ignore", + mode: "input", + }); expect(jsonSchema).toEqual({ not: { enum: [1, 2] } }); }); it("maps a single static mime_type to contentMediaType", () => { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, { type: "mime_type", mimeType: v("image/png") }, "file", { errorMode: "ignore" }); + applyRule(jsonSchema, { type: "mime_type", mimeType: v("image/png") }, "file", { + errorMode: "ignore", + mode: "input", + }); expect(jsonSchema).toEqual({ contentMediaType: "image/png" }); }); @@ -137,6 +161,7 @@ describe("applyRule", () => { const jsonSchema: JsonSchema = {}; applyRule(jsonSchema, { type: "mime_type", mimeType: v(["image/png", "image/jpeg"]) }, "file", { errorMode: "ignore", + mode: "input", }); expect(jsonSchema).toEqual({}); @@ -154,18 +179,22 @@ describe("applyRule", () => { { type: "conditional" as const, cases: [] }, ]) { const jsonSchema: JsonSchema = {}; - applyRule(jsonSchema, rule, "string", { errorMode: "ignore" }); + applyRule(jsonSchema, rule, "string", { errorMode: "ignore", mode: "input" }); expect(jsonSchema).toEqual({}); } }); it("throws when errorMode is 'throw' and a rule value is not static", () => { - expect(() => applyRule({}, { type: "min", min: ref("other") }, "number", { errorMode: "throw" })).toThrow(); + expect(() => + applyRule({}, { type: "min", min: ref("other") }, "number", { errorMode: "throw", mode: "input" }) + ).toThrow(); }); it("does not throw or warn when errorMode is 'ignore'", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - expect(() => applyRule({}, { type: "min", min: ref("other") }, "number", { errorMode: "ignore" })).not.toThrow(); + expect(() => + applyRule({}, { type: "min", min: ref("other") }, "number", { errorMode: "ignore", mode: "input" }) + ).not.toThrow(); expect(warn).not.toHaveBeenCalled(); warn.mockRestore(); }); diff --git a/packages/to-json-schema/src/convert-schema.test.ts b/packages/to-json-schema/src/convert-schema.test.ts index 4547493..c7410b7 100644 --- a/packages/to-json-schema/src/convert-schema.test.ts +++ b/packages/to-json-schema/src/convert-schema.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { convertSchema } from "./convert-schema"; import type { ConversionContext } from "./types"; -const ctx: ConversionContext = { errorMode: "ignore" }; +const ctx: ConversionContext = { errorMode: "ignore", mode: "input" }; describe("convertSchema", () => { it("converts primitive schemas", () => { @@ -41,7 +41,7 @@ describe("convertSchema", () => { options: [{ enabled: eq(ref("type"), v("x")), value: "d" }], }; - expect(convertSchema(schema, { errorMode: "warn" })).toEqual({ type: "string", enum: ["d"] }); + expect(convertSchema(schema, { errorMode: "warn", mode: "input" })).toEqual({ type: "string", enum: ["d"] }); expect(warn).toHaveBeenCalled(); warn.mockRestore(); }); @@ -89,6 +89,53 @@ describe("convertSchema", () => { }); }); + it("omits expression fields from an object in 'input' mode (default) but includes them in 'output' mode", () => { + const schema = object({ + name: string(), + computed: { type: "expression", value: v(1) }, + }); + + expect(convertSchema(schema, { errorMode: "ignore", mode: "input" })).toEqual({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }); + + expect(convertSchema(schema, { errorMode: "ignore", mode: "output" })).toEqual({ + type: "object", + properties: { name: { type: "string" }, computed: {} }, + required: ["name", "computed"], + }); + }); + + it("omits expression fields from a discriminated union member in 'input' mode but includes them in 'output' mode", () => { + const schema: Schema = { + type: "discriminated_union", + key: "kind", + schemas: [{ kind: "a", value: string(), computed: { type: "expression", value: v(1) } }], + }; + + expect(convertSchema(schema, { errorMode: "ignore", mode: "input" })).toEqual({ + oneOf: [ + { + type: "object", + properties: { kind: { const: "a" }, value: { type: "string" } }, + required: ["kind", "value"], + }, + ], + }); + + expect(convertSchema(schema, { errorMode: "ignore", mode: "output" })).toEqual({ + oneOf: [ + { + type: "object", + properties: { kind: { const: "a" }, value: { type: "string" }, computed: {} }, + required: ["kind", "value", "computed"], + }, + ], + }); + }); + it("converts a discriminated union to oneOf, dropping non-schema sibling entries", () => { // Built as a plain schema literal: the discriminatedUnion() fluent builder's CheckMember // constraint statically forbids non-schema sibling fields, but the underlying diff --git a/packages/to-json-schema/src/convert-schema.ts b/packages/to-json-schema/src/convert-schema.ts index 4bbe06b..f28f976 100644 --- a/packages/to-json-schema/src/convert-schema.ts +++ b/packages/to-json-schema/src/convert-schema.ts @@ -57,6 +57,10 @@ function isSchema(value: Schema | string | number | boolean): value is Schema { return typeof value === "object" && value !== null && "type" in value; } +function shouldOmitField(schema: Schema, context: ConversionContext): boolean { + return context.mode === "input" && schema.type === SchemaType.EXPRESSION; +} + function applyPrivacyWrapper(schema: Schema, innerSchema: JsonSchema): JsonSchema { if (schema.private !== true) { return innerSchema; @@ -151,6 +155,8 @@ function convertSchemaKind(schema: Schema, context: ConversionContext): JsonSche case SchemaType.EXPRESSION: { // Expression values are computed at runtime; their type can't be known ahead of time. + // As an object/discriminated-union field, this is only reached in "output" mode — + // in "input" mode such fields are omitted entirely by shouldOmitField(). return {}; } @@ -168,6 +174,10 @@ function convertSchemaKind(schema: Schema, context: ConversionContext): JsonSche const required: string[] = []; for (const [key, fieldSchema] of Object.entries(schema.fields)) { + if (shouldOmitField(fieldSchema, context)) { + continue; + } + properties[key] = convertSchema(fieldSchema, context); if (isMandatory(fieldSchema)) { required.push(key); @@ -190,7 +200,7 @@ function convertSchemaKind(schema: Schema, context: ConversionContext): JsonSche const required: string[] = [schema.key]; for (const [key, value] of Object.entries(member)) { - if (key === schema.key || !isSchema(value)) { + if (key === schema.key || !isSchema(value) || shouldOmitField(value, context)) { continue; } diff --git a/packages/to-json-schema/src/to-standard-json-schema.test.ts b/packages/to-json-schema/src/to-standard-json-schema.test.ts index 2830300..92661c1 100644 --- a/packages/to-json-schema/src/to-standard-json-schema.test.ts +++ b/packages/to-json-schema/src/to-standard-json-schema.test.ts @@ -45,4 +45,32 @@ describe("toStandardJsonSchema", () => { expect(() => toStandardJsonSchema(schema, { errorMode: "throw" })).toThrow(); }); + + it("defaults to mode 'input', omitting expression fields", () => { + const schema = object({ + name: string(), + fullName: { type: "expression", value: v(1) }, + }); + + expect(toStandardJsonSchema(schema)).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }); + }); + + it("includes expression fields when mode is 'output'", () => { + const schema = object({ + name: string(), + fullName: { type: "expression", value: v(1) }, + }); + + expect(toStandardJsonSchema(schema, { mode: "output" })).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { name: { type: "string" }, fullName: {} }, + required: ["name", "fullName"], + }); + }); }); diff --git a/packages/to-json-schema/src/to-standard-json-schema.ts b/packages/to-json-schema/src/to-standard-json-schema.ts index d3851b4..e0381ec 100644 --- a/packages/to-json-schema/src/to-standard-json-schema.ts +++ b/packages/to-json-schema/src/to-standard-json-schema.ts @@ -7,9 +7,14 @@ import type { ConversionConfig, JsonSchema } from "./types"; * * Rule values that depend on runtime data (references, predicates, * transformers) and schema kinds without a JSON Schema equivalent are - * handled according to `config.errorMode` (default `"warn"`). + * handled according to `config.errorMode` (default `"warn"`). Whether + * `expression` fields are included is controlled by `config.mode` + * (default `"input"`, which omits them). */ export function toStandardJsonSchema(schema: T, config: ConversionConfig = {}): JsonSchema { - const jsonSchema = convertSchema(schema, { errorMode: config.errorMode ?? "warn" }); + const jsonSchema = convertSchema(schema, { + errorMode: config.errorMode ?? "warn", + mode: config.mode ?? "input", + }); return { $schema: "https://json-schema.org/draft/2020-12/schema", ...jsonSchema }; } diff --git a/packages/to-json-schema/src/types.ts b/packages/to-json-schema/src/types.ts index 6266e21..37a3647 100644 --- a/packages/to-json-schema/src/types.ts +++ b/packages/to-json-schema/src/types.ts @@ -35,14 +35,28 @@ export interface JsonSchema { */ export type ErrorMode = "throw" | "warn" | "ignore"; +/** + * Whether the conversion targets the data a consumer sends in (`"input"`) + * or the data dynz produces after validation (`"output"`). `expression` + * schemas are computed/derived values that are never part of the input, so + * in `"input"` mode they're silently removed from the generated schema; in + * `"output"` mode they're included (using their best-effort mapping). + */ +export type ConversionMode = "input" | "output"; + export interface ConversionConfig { /** * Policy for handling unsupported/unresolvable rule values and schema * kinds. Defaults to `"warn"`. */ errorMode?: ErrorMode; + /** + * Whether to convert for input or output data. Defaults to `"input"`. + */ + mode?: ConversionMode; } export interface ConversionContext { readonly errorMode: ErrorMode; + readonly mode: ConversionMode; }