Skip to content

Commit 7e775f7

Browse files
authored
feat: getFieldValue helper (#1112)
1 parent fe389a8 commit 7e775f7

File tree

13 files changed

+706
-12
lines changed

13 files changed

+706
-12
lines changed

.changeset/add-get-field-value.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
'@conform-to/dom': minor
3+
'@conform-to/react': minor
4+
---
5+
6+
Add `getFieldValue` helper to extract and validate field values from FormData or URLSearchParams.
7+
8+
```ts
9+
import { getFieldValue } from '@conform-to/react/future';
10+
11+
// Basic: returns `unknown`
12+
const email = getFieldValue(formData, 'email');
13+
14+
// With type guard: returns `string`, throws if not a string
15+
const name = getFieldValue(formData, 'name', { type: 'string' });
16+
17+
// File type: returns `File`, throws if not a File
18+
const avatar = getFieldValue(formData, 'avatar', { type: 'file' });
19+
20+
// Object type: parses nested fields into `{ city: unknown, ... }`
21+
const address = getFieldValue<Address>(formData, 'address', { type: 'object' });
22+
23+
// Array: returns `unknown[]`
24+
const tags = getFieldValue(formData, 'tags', { array: true });
25+
26+
// Array of objects: returns `Array<{ name: unknown, ... }>`
27+
const items = getFieldValue<Item[]>(formData, 'items', {
28+
type: 'object',
29+
array: true,
30+
});
31+
32+
// Optional: returns `string | undefined`, no error if missing
33+
const bio = getFieldValue(formData, 'bio', { type: 'string', optional: true });
34+
```
35+
36+
It also infers types from the field name:
37+
38+
```ts
39+
import { useForm, useFormData, getFieldValue } from '@conform-to/react/future';
40+
41+
function Example() {
42+
const { form, fields } = useForm();
43+
// Retrieves the value of the `address` fieldset as an object, e.g. `{ city: unknown; ... }`
44+
const address = useFormData(form.id, (formData) =>
45+
getFieldValue(formData, fields.address.name, { type: 'object' }),
46+
);
47+
48+
// ...
49+
}
50+
```
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# getFieldValue
2+
3+
> The `getFieldValue` function is part of Conform's future export. These APIs are experimental and may change in minor versions. [Learn more](https://github.com/edmundhung/conform/discussions/954)
4+
5+
A utility function that extracts and validates field values from `FormData` or `URLSearchParams`. It supports type guards for runtime validation and can parse nested objects from field naming conventions.
6+
7+
```ts
8+
import { getFieldValue } from '@conform-to/react/future';
9+
10+
const value = getFieldValue(formData, name, options);
11+
```
12+
13+
## Parameters
14+
15+
### `formData: FormData | URLSearchParams`
16+
17+
The form data to extract values from. Can be:
18+
19+
- A `FormData` object from a form submission
20+
- A `URLSearchParams` object from a URL query string
21+
22+
### `name: string | FieldName<T>`
23+
24+
The field name to retrieve. Supports nested field names using dot notation and array indices:
25+
26+
- `email` → retrieves the `email` field
27+
- `address.city` → retrieves the nested `city` field within `address`
28+
- `items[0]` → retrieves the first item in the `items` array
29+
30+
When using a `FieldName<T>` from Conform's field metadata, the return type is automatically inferred.
31+
32+
### `options.type?: 'string' | 'file' | 'object'`
33+
34+
Specifies the expected type of the field value. When set, the function validates the value at runtime and throws an error if the type doesn't match.
35+
36+
- `'string'` - Expects a string value
37+
- `'file'` - Expects a `File` object
38+
- `'object'` - Expects a plain object
39+
40+
### `options.array?: boolean`
41+
42+
When `true`, expects the value to be an array.
43+
44+
### `options.optional?: boolean`
45+
46+
When `true`, returns `undefined` for missing fields instead of throwing an error. Type validation still applies when the field exists.
47+
48+
## Returns
49+
50+
The return type depends on the options provided:
51+
52+
| Options | Return Type |
53+
| --------------------------------- | --------------------------- |
54+
| (none) | `unknown` |
55+
| `{ type: 'string' }` | `string` |
56+
| `{ type: 'file' }` | `File` |
57+
| `{ type: 'object' }` | `{ [key]: unknown }` |
58+
| `{ array: true }` | `unknown[]` |
59+
| `{ type: 'string', array: true }` | `string[]` |
60+
| `{ type: 'file', array: true }` | `File[]` |
61+
| `{ type: 'object', array: true }` | `Array<{ [key]: unknown }>` |
62+
| `{ optional: true }` | `... \| undefined` |
63+
64+
## Example
65+
66+
### Basic field retrieval
67+
68+
```ts
69+
const formData = new FormData();
70+
formData.append('email', 'user@example.com');
71+
formData.append('tags', 'react');
72+
formData.append('tags', 'typescript');
73+
74+
// Get single value
75+
const email = getFieldValue(formData, 'email'); // 'user@example.com'
76+
77+
// Get all values as array
78+
const tags = getFieldValue(formData, 'tags', { array: true }); // ['react', 'typescript']
79+
```
80+
81+
### Parsing nested objects
82+
83+
```ts
84+
const formData = new FormData();
85+
formData.append('address.city', 'New York');
86+
formData.append('address.zipcode', '10001');
87+
88+
const address = getFieldValue(formData, 'address', { type: 'object' });
89+
// { city: 'New York', zipcode: '10001' }
90+
```
91+
92+
### Parsing array of objects
93+
94+
```ts
95+
const formData = new FormData();
96+
formData.append('items[0].name', 'Item 1');
97+
formData.append('items[0].price', '10');
98+
formData.append('items[1].name', 'Item 2');
99+
formData.append('items[1].price', '20');
100+
101+
const items = getFieldValue(formData, 'items', { type: 'object', array: true });
102+
// [{ name: 'Item 1', price: '10' }, { name: 'Item 2', price: '20' }]
103+
```
104+
105+
### With useFormData for live updates
106+
107+
```tsx
108+
import { useForm, useFormData, getFieldValue } from '@conform-to/react/future';
109+
110+
function AddressForm() {
111+
const { form, fields } = useForm();
112+
113+
const addressFields = fields.address.getFieldset();
114+
// Subscribe to address changes with type inference from field name
115+
const address = useFormData(form.id, (formData) =>
116+
getFieldValue(formData, fields.address.name, { type: 'object' }),
117+
);
118+
119+
return (
120+
<form {...form.props}>
121+
<input name={addressFields.city.name} />
122+
<input name={addressFields.street.name} />
123+
124+
{/* Show live parsed address */}
125+
<pre>{JSON.stringify(address, null, 2)}</pre>
126+
</form>
127+
);
128+
}
129+
```

guide/app/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const menus: { [code: string]: Menu[] } = {
6161
{ title: 'useIntent', to: '/api/react/future/useIntent' },
6262
{ title: 'parseSubmission', to: '/api/react/future/parseSubmission' },
6363
{ title: 'report', to: '/api/react/future/report' },
64+
{ title: 'getFieldValue', to: '/api/react/future/getFieldValue' },
6465
{ title: 'isDirty', to: '/api/react/future/isDirty' },
6566
{ title: 'FormProvider', to: '/api/react/future/FormProvider' },
6667
{

packages/conform-dom/formdata.ts

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import type {
22
FormError,
33
FormValue,
4+
FieldName,
45
JsonPrimitive,
56
Serialize,
67
SerializedValue,
78
Submission,
89
SubmissionResult,
10+
UnknownObject,
911
} from './types';
1012
import { isGlobalInstance, isSubmitter } from './dom';
11-
import { deepEqual, isPlainObject, stripFiles } from './util';
13+
import { deepEqual, getTypeName, isPlainObject, stripFiles } from './util';
1214
import type { StandardSchemaIssue } from './standard-schema';
1315
import { formatIssues } from './standard-schema';
1416

@@ -872,3 +874,172 @@ export function serialize(value: unknown): SerializedValue | null | undefined {
872874

873875
return serializePrimitive(value);
874876
}
877+
878+
/**
879+
* Retrieve a field value from FormData with optional type guards.
880+
*
881+
* @example
882+
* // Basic field access: return `unknown`
883+
* const email = getFieldValue(formData, 'email');
884+
* // String type: returns `string`
885+
* const name = getFieldValue(formData, 'name', { type: 'string' });
886+
* // File type: returns `File`
887+
* const avatar = getFieldValue(formData, 'avatar', { type: 'file' });
888+
* // Object type: returns { city: unknown, ... }
889+
* const address = getFieldValue<Address>(formData, 'address', { type: 'object' });
890+
* // Array: returns `unknown[]`
891+
* const tags = getFieldValue(formData, 'tags', { array: true });
892+
* // Array with object type: returns `Array<{ name: unknown, ... }>`
893+
* const items = getFieldValue<Item[]>(formData, 'items', { type: 'object', array: true });
894+
* // Optional string type: returns `string | undefined`
895+
* const bio = getFieldValue(formData, 'bio', { type: 'string', optional: true });
896+
*/
897+
export function getFieldValue<
898+
FieldShape extends Array<Record<string, unknown>>,
899+
>(
900+
formData: FormData | URLSearchParams,
901+
name: FieldName<FieldShape>,
902+
options: { type: 'object'; array: true; optional: true },
903+
): FieldShape extends Array<infer Item extends Record<string, unknown>>
904+
? Array<UnknownObject<Item>> | undefined
905+
: never;
906+
export function getFieldValue<
907+
FieldShape extends Array<Record<string, unknown>>,
908+
>(
909+
formData: FormData | URLSearchParams,
910+
name: FieldName<FieldShape>,
911+
options: { type: 'object'; array: true },
912+
): FieldShape extends Array<infer Item extends Record<string, unknown>>
913+
? Array<UnknownObject<Item>>
914+
: never;
915+
export function getFieldValue<
916+
FieldShape extends Record<string, unknown> = Record<string, unknown>,
917+
>(
918+
formData: FormData | URLSearchParams,
919+
name: FieldName<FieldShape>,
920+
options: { type: 'object'; optional: true },
921+
): UnknownObject<FieldShape> | undefined;
922+
export function getFieldValue<
923+
FieldShape extends Record<string, unknown> = Record<string, unknown>,
924+
>(
925+
formData: FormData | URLSearchParams,
926+
name: FieldName<FieldShape>,
927+
options: { type: 'object' },
928+
): UnknownObject<FieldShape>;
929+
export function getFieldValue<FieldShape>(
930+
formData: FormData | URLSearchParams,
931+
name: FieldName<FieldShape>,
932+
options: { type: 'string'; array: true; optional: true },
933+
): string[] | undefined;
934+
export function getFieldValue<FieldShape>(
935+
formData: FormData | URLSearchParams,
936+
name: FieldName<FieldShape>,
937+
options: { type: 'string'; array: true },
938+
): string[];
939+
export function getFieldValue<FieldShape>(
940+
formData: FormData | URLSearchParams,
941+
name: FieldName<FieldShape>,
942+
options: { type: 'string'; optional: true },
943+
): string | undefined;
944+
export function getFieldValue<FieldShape>(
945+
formData: FormData | URLSearchParams,
946+
name: FieldName<FieldShape>,
947+
options: { type: 'string' },
948+
): string;
949+
export function getFieldValue<FieldShape>(
950+
formData: FormData | URLSearchParams,
951+
name: FieldName<FieldShape>,
952+
options: { type: 'file'; array: true; optional: true },
953+
): File[] | undefined;
954+
export function getFieldValue<FieldShape>(
955+
formData: FormData | URLSearchParams,
956+
name: FieldName<FieldShape>,
957+
options: { type: 'file'; array: true },
958+
): File[];
959+
export function getFieldValue<FieldShape>(
960+
formData: FormData | URLSearchParams,
961+
name: FieldName<FieldShape>,
962+
options: { type: 'file'; optional: true },
963+
): File | undefined;
964+
export function getFieldValue<FieldShape>(
965+
formData: FormData | URLSearchParams,
966+
name: FieldName<FieldShape>,
967+
options: { type: 'file' },
968+
): File;
969+
export function getFieldValue<FieldShape>(
970+
formData: FormData | URLSearchParams,
971+
name: FieldName<FieldShape>,
972+
options: { array: true; optional: true },
973+
): Array<unknown> | undefined;
974+
export function getFieldValue<FieldShape>(
975+
formData: FormData | URLSearchParams,
976+
name: FieldName<FieldShape>,
977+
options: { array: true },
978+
): Array<unknown>;
979+
export function getFieldValue<FieldShape>(
980+
formData: FormData | URLSearchParams,
981+
name: FieldName<FieldShape>,
982+
options?: { optional?: boolean },
983+
): unknown;
984+
export function getFieldValue<FieldShape>(
985+
formData: FormData | URLSearchParams,
986+
name: FieldName<FieldShape>,
987+
options?: {
988+
type?: 'object' | 'string' | 'file';
989+
array?: boolean;
990+
optional?: boolean;
991+
},
992+
): unknown {
993+
const { type, array, optional } = options ?? {};
994+
995+
let value: unknown;
996+
997+
// Check if formData has a direct entry
998+
if (formData.has(name)) {
999+
// Get value based on array option
1000+
value = array ? formData.getAll(name) : formData.get(name);
1001+
} else {
1002+
// Parse formData and use getValueAtPath
1003+
const submission = parseSubmission(formData, {
1004+
stripEmptyValues: false,
1005+
});
1006+
value = getValueAtPath(submission.payload, name);
1007+
}
1008+
1009+
// If optional and value is undefined, skip validation and return early
1010+
if (optional && value === undefined) {
1011+
return;
1012+
}
1013+
1014+
// Type guards - validate the value matches the expected type
1015+
if (array && !Array.isArray(value)) {
1016+
throw new Error(
1017+
`Expected field "${name}" to be an array, but got ${getTypeName(value)}`,
1018+
);
1019+
}
1020+
1021+
if (type) {
1022+
const items = array ? (value as unknown[]) : [value];
1023+
const predicate = {
1024+
string: (v: unknown) => typeof v === 'string',
1025+
file: (v: unknown) => v instanceof File,
1026+
object: isPlainObject,
1027+
}[type];
1028+
const typeName = {
1029+
string: 'a string',
1030+
file: 'a File',
1031+
object: 'an object',
1032+
}[type];
1033+
1034+
for (let i = 0; i < items.length; i++) {
1035+
if (!predicate(items[i])) {
1036+
const field = array ? `${name}[${i}]` : name;
1037+
throw new Error(
1038+
`Expected field "${field}" to be ${typeName}, but got ${getTypeName(items[i])}`,
1039+
);
1040+
}
1041+
}
1042+
}
1043+
1044+
return value;
1045+
}

packages/conform-dom/future/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export type {
22
Serialize,
3+
FieldName,
34
FormValue,
45
FormError,
56
Submission,
@@ -19,6 +20,7 @@ export {
1920
setValueAtPath,
2021
report,
2122
serialize,
23+
getFieldValue,
2224
} from '../formdata';
2325
export { isPlainObject, deepEqual } from '../util';
2426
export {

0 commit comments

Comments
 (0)