diff --git a/README.md b/README.md index f2bbf67..b97ba91 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,28 @@ wrap the primitive in an object, or use a controller (`$useController`) to imple For more information, see the React Hook Form docs on [`useFieldArray`](https://react-hook-form.com/docs/usefieldarray). +## Discriminating unions + +In case of a form that contains fields with object unions, the `$discriminate()` function may be used to narrow the type +using a specific member like this: + +```tsx +import { FC } from 'react'; + +type DiscriminatedForm = + | { __typename: 'foo'; foo: string; } + | { __typename: 'bar'; bar: number; } + +const DiscriminatedSubform: FC<{field: FormBuilder}> = ({field}) => { + const fooForm = field.$discriminate('__typename', 'foo'); + + return ; +}; +``` + +> [!IMPORTANT] +> `$discriminate` currently does **not** perform any runtime checks, it's strictly used for type narrowing at this time. + ## Compatibility with `useForm` Currently, `useFormBuilder` is almost compatible with `useForm`. This means you get the entire bag of tools provided by diff --git a/package.json b/package.json index 4eacf96..1a34e5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@atmina/formbuilder", - "version": "1.0.0", + "version": "1.1.0", "description": "A strongly-typed alternative API for React Hook Form.", "source": "src/index.ts", "main": "lib/index.js", @@ -73,5 +73,6 @@ "react-dom": { "optional": true } - } + }, + "packageManager": "yarn@1.22.21+sha256.dbed5b7e10c552ba0e1a545c948d5473bc6c5a28ce22a8fd27e493e3e5eb6370" } diff --git a/src/formbuilder.tsx b/src/formbuilder.tsx index 5008768..9a89f43 100644 --- a/src/formbuilder.tsx +++ b/src/formbuilder.tsx @@ -70,6 +70,12 @@ export type FormBuilder = FormBuilderRegisterFn & { options: Parameters>[2] ): void; $setFocus(options: SetFocusOptions): void; + $discriminate( + key: TKey, + value: TValue + ): IsUnknown extends 1 + ? FormBuilder + : FormBuilder>>; } & (T extends Primitive ? // Leaf node unknown @@ -152,7 +158,7 @@ export function createFormBuilder( return methods.register(currentPath, options as never); }) as FormBuilder, { - get(_, prop) { + get(_, prop, receiver) { let useCached = cache[prop]; if (useCached !== undefined) { return useCached; @@ -225,6 +231,9 @@ export function createFormBuilder( methods.setError(currentPath, value, options); }; break; + case "$discriminate": + useCached = () => receiver; + break; default: // Recurse useCached = createFormBuilder(methods, [ @@ -353,3 +362,5 @@ export type UseFormBuilderProps< TFieldValues extends FieldValues = FieldValues, TContext = any > = UseFormProps; + +type IsUnknown = unknown extends T ? (T extends unknown ? 1 : 0) : 0; diff --git a/src/types.test.tsx b/src/types.test.tsx index f13f252..42d45d1 100644 --- a/src/types.test.tsx +++ b/src/types.test.tsx @@ -110,6 +110,25 @@ describe("Types", () => { FormBuilder<{ things: { foo: string }[]; otherThings: { bar: string }[] }> >().toMatchTypeOf>(); + // discriminate helper + interface FooForm { + __typename: "foo"; + foo: string; + } + interface BarForm { + __typename: "bar"; + bar: number; + } + const discriminatorForm: FormBuilder = { + $discriminate: () => undefined, + } as any; + expectTypeOf( + discriminatorForm.$discriminate("__typename", "foo") + ).toMatchTypeOf>(); + expectTypeOf( + discriminatorForm.$discriminate("__typename", "bar") + ).toMatchTypeOf>(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any expectTypeOf>().toMatchTypeOf< FormBuilder