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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/arktype-utils.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@jhecht/arktype-utils": minor
---

## Summary

Updated utilities to allow for building nested files

### Detailed Changes

```ts
// Test nested objects with dot notation
const fd = new FormData();
fd.append('payments.id1.location', 'England');
fd.append('payments.id1.age', '37');
fd.append('payments.id2.location', 'New York');
const obj = formDataToObject(fd);

// Test nested arrays with bracket notation
const fd2 = new FormData();
fd2.append('locations[].name', 'England');
fd2.append('locations[].members[]', 'John');
fd2.append('locations[].members[]', 'Joe');
fd2.append('locations[].name', 'France');
const obj2 = formDataToObject(fd2);
```
37 changes: 37 additions & 0 deletions packages/arktype-utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,43 @@ const obj = formDataToObject(fd);
console.info(obj);
```

### Deeply Nested Objects and Arrays

`formDataToObject` supports advanced dot and bracket notation for deeply nested structures, including arrays of objects and nested arrays:

```ts
const fd = new FormData();
fd.append('payments.id1.location', 'England');
fd.append('payments.id1.age', '37');
fd.append('payments.id2.location', 'New York');
fd.append('payments.id2.age', '81');
fd.append('payments.id2.name', 'Steve');

const obj = formDataToObject(fd);
// {
// payments: {
// id1: { location: 'England', age: 37 },
// id2: { location: 'New York', age: 81, name: 'Steve' },
// }
// }

const fd2 = new FormData();
fd2.append('locations[].name', 'England');
fd2.append('locations[].members[]', 'John');
fd2.append('locations[].members[]', 'Joe');
fd2.append('locations[].name', 'France');
fd2.append('locations[].members[]', 'Francis');
fd2.append('locations[].members[]', 'Joe2');

const obj2 = formDataToObject(fd2);
// {
// locations: [
// { name: 'England', members: ['John', 'Joe'] },
// { name: 'France', members: ['Francis', 'Joe2'] }
// ]
// }
```

## Validating Data with ArkType

I created this wrapper, which `throws` if there are any `error` values. It takes two arguments, one being a `FormData` object, and the other a `type` or `scope` call from ArkType, which serves as the validator for the object-ified `FormData`.
Expand Down
35 changes: 35 additions & 0 deletions packages/arktype-utils/src/formData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,41 @@ describe('formDataToObject', () => {
});
});

describe('formDataToObject - nested keys', () => {
it('parses nested objects with dot notation', () => {
const formData = new FormData();
formData.append('payments.id1.location', 'England');
formData.append('payments.id1.age', '37');
formData.append('payments.id2.location', 'New York');
formData.append('payments.id2.age', '81');
formData.append('payments.id2.name', 'Steve');
const obj = formDataToObject(formData);
expect(obj).toMatchObject({
payments: {
id1: { location: 'England', age: 37 },
id2: { location: 'New York', age: 81, name: 'Steve' },
},
});
});

it('parses nested arrays and objects with bracket/dot notation', () => {
const formData = new FormData();
formData.append('locations[].name', 'England');
formData.append('locations[].members[]', 'John');
formData.append('locations[].members[]', 'Joe');
formData.append('locations[].name', 'France');
formData.append('locations[].members[]', 'Francis');
formData.append('locations[].members[]', 'Joe2');
const obj = formDataToObject(formData);
expect(obj).toMatchObject({
locations: [
{ name: 'England', members: ['John', 'Joe'] },
{ name: 'France', members: ['Francis', 'Joe2'] },
],
});
});
});

describe('validateFormData', () => {
const fileType = type(['instanceof', File]);
it('Validates simple object', () => {
Expand Down
223 changes: 170 additions & 53 deletions packages/arktype-utils/src/formData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ type FilterFn = (a: EntriesTouple) => boolean;

type FormDataObjectEntry = FormDataEntryValue | number | boolean | bigint;

const nameKeyExtractor = /(?<name>.*)\[(?<index>.*)\]/;
const digitCheck = /^\d+$/;

type MagicObject = {
Expand Down Expand Up @@ -52,68 +51,186 @@ function makeMagicObject(init: EntriesTouple[] = []): MagicObject {
* @param filterFn an optional filtering function to remove some values from the end object
* @returns an object mapped from the entries.
*/
function parseKeyPath(key: string): (string | number)[] {
// Split on dots not inside brackets, then handle bracketed indices/keys
const path: (string | number)[] = [];
let buffer = '';
let inBracket = false;
for (let i = 0; i < key.length; i++) {
const [char] = key;
if (char === '[') {
if (buffer) {
path.push(buffer);
buffer = '';
}
inBracket = true;
} else if (char === ']') {
if (buffer) {
// If it's a number, treat as array index
if (/^\d+$/.test(buffer)) path.push(Number(buffer));
else path.push(buffer);
buffer = '';
} else {
// Support for [] as array push
path.push('');
}
inBracket = false;
} else if (char === '.' && !inBracket) {
if (buffer) {
path.push(buffer);
buffer = '';
}
} else buffer += char;
}
if (buffer) path.push(buffer);
return path;
}

/**
* Recursively sets a value in a nested object/array structure based on a path.
* Handles creation of arrays/objects as needed for deep assignment.
* Supports array push (''), numeric indices, and string keys.
*
* @param obj The root object or array to mutate
* @param path The path array (from parseKeyPath)
* @param value The value to set
*/
function setDeep(
obj: Record<string, unknown> | unknown[],
path: (string | number)[],
value: unknown,
) {
// Full disclosure: this was generated by AI. Should probably deep dive into it
// in the future but for right now it works.
let curr: Record<string, unknown> | unknown[] = obj;
for (let i = 0; i < path.length - 1; i++) {
const { [i]: key } = path;
const { [i + 1]: nextKey } = path;
// Handle array push (''), numeric index, or string key
if (key === '') {
if (!Array.isArray(curr)) curr = [];
const arr = curr as unknown[];
if (!arr[arr.length - 1] || typeof nextKey === 'string') arr.push({});
const [last] = arr.slice(-1);
curr =
Array.isArray(last) || typeof last === 'object'
? (last as Record<string, unknown> | unknown[])
: {};
} else if (typeof key === 'number') {
if (!Array.isArray(curr)) curr = [];
const arr = curr as unknown[];
if (!arr[key]) arr[key] = typeof nextKey === 'number' ? [] : {};
const { [key]: next } = arr;
curr =
Array.isArray(next) || typeof next === 'object'
? (next as Record<string, unknown> | unknown[])
: {};
} else if (typeof key === 'string') {
if (!(typeof curr === 'object' && curr !== null && key in curr)) {
(curr as Record<string, unknown>)[key] =
typeof nextKey === 'number' ? [] : {};
}
const objRec = curr as Record<string, unknown>;
const { [key]: next } = objRec;
curr =
Array.isArray(next) || typeof next === 'object'
? (next as Record<string, unknown> | unknown[])
: {};
}
}
const [lastKey] = path.slice(-1);
if (lastKey === '') {
if (!Array.isArray(curr)) curr = [];
(curr as unknown[]).push(value);
} else if (typeof lastKey === 'number') {
if (!Array.isArray(curr)) curr = [];
(curr as unknown[])[lastKey] = value;
} else if (typeof lastKey === 'string')
(curr as Record<string, unknown>)[lastKey] = value;
}

/**
* Converts a FormData object into a deeply nested JavaScript object.
* Supports dot and bracket notation for nested objects/arrays, e.g.:
* - 'foo.bar' → { foo: { bar: value } }
* - 'arr[]' → { arr: [value, ...] }
* - 'obj[key]' → { obj: { key: value } }
* - 'arr[].prop' → { arr: [{ prop: value }, ...] }
* Handles type conversion for numbers, booleans, and bigints.
*
* @param fd The FormData instance
* @param filterFn Optional filter function for entries
* @returns The resulting JS object
*/
export function formDataToObject(
fd: FormData,
filterFn: FilterFn = () => true,
) {
const ret: Record<string, unknown> = {};
// Map of key name into magic type which converts its entries to either an object
// or an array.
const info = new Map<string, ReturnType<typeof makeMagicObject>>();
// eslint-disable-next-line prefer-const
for (let [iterator, value] of fd.entries()) {
// If the key does not match this filter, continue the loop
if (!filterFn([iterator, value])) continue;
// run the iterator against the name key extractor
const matches = iterator.match(nameKeyExtractor);
// If we do not have matches, or the index match is empty, we go here.
if (matches === null || matches[2] === '') {
// Grab all of the values for the current iterator
const all = fd.getAll(iterator);
// If the length of all entries for this iterator is 1 AND the iterator name does not end
// with [] (indicating the user wants this to be an array) we drop in here
if (all.length === 1 && !iterator.endsWith('[]')) {
// set the value on the return object
ret[iterator] =
typeof all[0] === 'string' ? stringToJSValue(all[0]) : all[0];
// don't need the rest of the loop values here, so we forcibly continue
continue;
// For grouping array objects by their base path
const arrayGroups = new Map<
string,
{ current: Record<string, unknown>; arr: Record<string, unknown>[] }
>();
// For collecting repeated simple keys and top-level arrays
const simpleArrays = new Map<string, unknown[]>();
for (const [rawKey, value] of fd.entries()) {
if (!filterFn([rawKey, value])) continue;
const path = parseKeyPath(rawKey);
let v: unknown = value;
if (typeof value === 'string') v = stringToJSValue(value);
// Handle top-level arrays (e.g., names[])
if (path.length === 2 && path[1] === '') {
const key = String(path[0]);
if (!simpleArrays.has(key)) simpleArrays.set(key, []);
simpleArrays.get(key)!.push(v);
continue;
}
// Handle nested arrays (e.g., locations[].members[])
const arrayIdx = path.findIndex((p) => p === '');
if (arrayIdx !== -1) {
const basePath = path.slice(0, arrayIdx).join('.');
if (!arrayGroups.has(basePath))
arrayGroups.set(basePath, { current: {}, arr: [] });
const group = arrayGroups.get(basePath)!;
// If this is a new object in the array, push current and start new
if (
Object.keys(group.current).length > 0 &&
path[arrayIdx + 1] === 'name'
) {
group.arr.push(group.current);
group.current = {};
}
// If the iterator includes an opening [], let's assume we don't want the `[]` to be included
// so we trim it out here
if (iterator.includes('['))
iterator = iterator.slice(0, iterator.indexOf('['));

// Set the iterator value on returned object
ret[iterator] = all.map((v) =>
typeof v === 'string' ? stringToJSValue(v) : v,
);
// If the property is a nested array (e.g., members[]), collect as array
if (path.length === arrayIdx + 3 && path[arrayIdx + 2] === '') {
const prop = String(path[arrayIdx + 1]);
if (!Array.isArray(group.current[prop])) group.current[prop] = [];
(group.current[prop] as unknown[]).push(v);
} else setDeep(group.current, path.slice(arrayIdx + 1), v);
} else {
// If we have matches, there's a bit more processing required
const { groups } = matches;
// Check to ensure our groups are there. It shouldn't be possible to have matches
// without groups in modern JS, but c'est la vie
if (groups === undefined) continue;
// pull out the name and index. we add defaults so TS doesn't yell at us about possibly
// being undefined
const { name = '', index = '' } = groups;
// Grab the magic item from the info map.
let magic = info.get(name);
// If we don't have a magic item, make one and set it
if (!magic) {
magic = makeMagicObject();
info.set(name, magic);
}

magic.add(index, value);
// For repeated simple keys (not arrays)
const keyStr = path.join('.');
if (fd.getAll(rawKey).length > 1) {
if (!simpleArrays.has(keyStr)) {
simpleArrays.set(
keyStr,
fd
.getAll(rawKey)
.map((val) =>
typeof val === 'string' ? stringToJSValue(val) : val,
),
);
}
} else setDeep(ret, path, v);
}
}

// Consolidation of items in the info values.
for (const [key, magic] of info.entries()) {
if (!ret[key]) ret[key] = magic.toJS();
else console.error(`Key ${key} already exists in object.`);
// Assign grouped arrays
for (const [base, { current, arr }] of arrayGroups.entries()) {
if (Object.keys(current).length > 0) arr.push(current);
setDeep(ret, base.split('.'), arr);
}
// Assign simple arrays
for (const [key, arr] of simpleArrays.entries()) setDeep(ret, [key], arr);
return ret;
}

Expand Down
Loading