Skip to content

Instantly share code, notes, and snippets.

@sk22
Last active June 25, 2025 13:29
Show Gist options
  • Save sk22/382e0850e01d618cbf72f73ad3d8e98e to your computer and use it in GitHub Desktop.
Save sk22/382e0850e01d618cbf72f73ad3d8e98e to your computer and use it in GitHub Desktop.
Function that recursively/deeply turns an object into placeholders and values
import { expect, test } from 'vitest';
import { objectToPlaceholders } from './objectToPlaceholders.ts';
const date = new Date();
const blob = new Blob();
const file = new File([], 'le file');
const obj = { a: { b: date, foo: 'bar', baz: 123, c: [blob, file] } };
test('objectToPlaceholders', () => {
const { placeholders, values } = objectToPlaceholders(obj);
expect(placeholders).toEqual({ a: { b: 0, foo: 1, baz: 2, c: [3, 4] } });
expect(values).toEqual([date, 'bar', 123, blob, file]);
});
const objPrototype = Object.getPrototypeOf({});
const isPlainObject = (value: unknown) =>
typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === objPrototype;
/**
* I am a machine that turns
*
* ```ts
* { a: { b: file0, foo: 'bar', baz: 123, c: [file1, file2] } }
* ```
*
* into
*
* ```ts
* {
* placeholders: { a: { b: 0, foo: 1, baz: 2, c: [ 3, 4 ] } },
* values: [ file0, 'bar', 123, file1, file2 ]
* }
* ```
*/
export function objectToPlaceholders(obj: Record<string, unknown>): {
values: unknown[];
placeholders: Record<string, unknown>;
} {
const values: unknown[] = [];
const placeholders: Record<string, unknown> = {};
function processArray(key: number, value: unknown, currentPlaceholder: unknown[]) {
if (isPlainObject(value)) {
const nextPlaceholder = (currentPlaceholder[key] = {});
recurse(value as Record<string, unknown>, nextPlaceholder);
} else if (Array.isArray(value)) {
const nextPlaceholder = (currentPlaceholder[key] = []);
values.forEach((item, index) => processArray(index, item, nextPlaceholder));
} else {
values.push(value);
currentPlaceholder[key] = values.length - 1;
}
}
function processItem(key: string, value: unknown, currentPlaceholder: Record<string | number, unknown>) {
if (isPlainObject(value)) {
const nextPlaceholder = (currentPlaceholder[key] = {});
recurse(value as Record<string, unknown>, nextPlaceholder);
} else if (Array.isArray(value)) {
const nextPlaceholder = (currentPlaceholder[key] = []);
value.forEach((item, index) => processArray(index, item, nextPlaceholder));
} else {
values.push(value);
currentPlaceholder[key] = values.length - 1;
}
}
function recurse(currentObj: Record<string, unknown>, currentPlaceholder: Record<string, unknown>) {
for (const [key, value] of Object.entries(currentObj)) {
processItem(key, value, currentPlaceholder);
}
return currentPlaceholder;
}
recurse(obj, placeholders);
return { values, placeholders };
}
import { expect, test } from 'vitest';
import { bodySerializerFormData } from './serializer';
const blob = new Blob();
const file1 = new File([], 'file1.pdf');
const file2 = new File([], 'file2.pdf');
test('bodySerializerFormData with deep values', () => {
const obj = { a: { b: blob, foo: 'bar', baz: 123, c: [file1, file2] } };
const fd = bodySerializerFormData(obj);
// FormData converts Blob into File (subclass of Blob)
expect(fd.get('a.b')).toBeInstanceOf(Blob);
expect(fd.get('a.foo')).toBe('bar');
expect(fd.getAll('a.c')).toHaveLength(2);
expect(fd.getAll('a.c')).toEqual([file1, file2]);
});
test('bodySerializerFormData with null/undefined values', () => {
const obj = { a: 'yes', b: undefined, c: null, d: file1, files: [null, undefined, file2] };
const fd = bodySerializerFormData(obj);
expect(fd.get('a')).toBe('yes');
expect(fd.get('b')).toBe(null);
expect(fd.get('c')).toBe(null);
expect(fd.get('d')).toBe(file1);
expect(fd.getAll('files')).toEqual([file2]);
});
test('bodySerializerFormData with shallow values', () => {
const fd = bodySerializerFormData({ guid: 'asdf', files: [file1, file2] });
expect(fd.get('guid')).toBe('asdf');
expect(fd.getAll('files')).toEqual([file1, file2]);
});
import { stringify } from 'qs';
import { objectToPlaceholders } from './objectToPlaceholders.ts';
/**
* @param body Something like `{ file: File, otherFile?: File }` or `{ files: File[] }`
* @returns A FormData instance to be passed as a request's body
*/
export function bodySerializerFormData(body?: Record<string, unknown>) {
const fd = new FormData();
if (!body) return fd;
const {
placeholders, // { a: { b: 0, c: [1, 2] } }
values, // ['hello', 'wide', 'world']
} = objectToPlaceholders(body);
// { a: { b: 0, c: [1, 2] } } -> { 'a.b' => '0', 'a.c[0]' => '1', 'a.c[1]' => '2' }
const search = new URLSearchParams(querySerializer(placeholders));
for (const [key, index] of search) {
// `array[0]` -> `array` (because form data can have multiple values for the same name)
const name = key.replace(/\[\d+\]$/, '');
const value = values[Number(index)];
if (typeof value === 'string' || value instanceof Blob) fd.append(name, value);
}
return fd;
}
export function querySerializer(query: unknown) {
// backend expects object arrays encoded like `Attachments[0].Title=this works` (+ URI encoded)
return stringify(query, { allowDots: true });
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment