Skip to content
Open
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
64 changes: 64 additions & 0 deletions src/codecs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { FieldName } from "./fields";

export abstract class BaseCodec<T> {
constructor(public suffix: string) {
this.encodeName = this.encodeName.bind(this)
}
encodeName(fieldName: FieldName<T>) {
return fieldName.toString() + ':' + this.suffix;
}
abstract decodeValue(value: FormDataEntryValue): T;
}

export interface Codec<T> extends BaseCodec<T> { }

export class StrCodec extends BaseCodec<string> {
constructor() {
super('string')
}
decodeValue(value: FormDataEntryValue) {
if (typeof value === 'string') {
return value;
}
throw new Error(`Expected value to be string but found ${value}`);
}
}

export const strCodec = new StrCodec()
export const asStr = strCodec.encodeName

export class NumCodec extends BaseCodec<number> {
constructor() {
super('number')
}
decodeValue(value: FormDataEntryValue) {
if (typeof value === 'string') {
return Number(value)
}
throw new Error(`Expected value to be string but found ${value}`);
}
}

export const numCodec = new NumCodec()
export const asNum = numCodec.encodeName

export class BoolCodec extends BaseCodec<boolean> {
constructor() {
super('boolean')
}
decodeValue(value: FormDataEntryValue) {
return value === 'true' || value === 'on'
}
}

export const boolCodec = new BoolCodec()
export const asBool = boolCodec.encodeName

export const mapCodecsBySuffix = (codecs: Codec<unknown>[]) =>
Object.fromEntries(codecs.map(it => [it.suffix, it]))

export const defaultCodecsBySuffix = mapCodecsBySuffix([
strCodec,
numCodec,
boolCodec
])
15 changes: 15 additions & 0 deletions src/extract_form.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,18 @@ test('extractFormData', () => {
expect(files.object).toBe(undefined);
expect(files.array[0]).toBeInstanceOf(Blob);
});

test('extractFormData with codecs', () => {
const formdata = new FormData();

formdata.set('name:string', 'name');
formdata.set('age:number', '90');
formdata.set('active:boolean', 'on');

const { data, fields, files } = extractFormData(formdata);

expect(data.name).toBe('name');
expect(data.age).toBe(90);
expect(data.active).toBe(true);

})
22 changes: 20 additions & 2 deletions src/extract_form.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Codec, defaultCodecsBySuffix, mapCodecsBySuffix } from './codecs';
import { set } from './set';

type GetFormData<T> = T extends Record<string, unknown>
Expand Down Expand Up @@ -38,15 +39,24 @@ type ExtractFormData<T> = {

export function extractFormData<T = any>(
formData: FormData,
codecs?: Codec<any>[]
): ExtractFormData<T> {
const codecsMapping = codecs
? {
...defaultCodecsBySuffix,
...mapCodecsBySuffix(codecs)
}
: defaultCodecsBySuffix

const data: any = {};
const fields: any = {};
const files: any = {};

for (const [key, value] of formData.entries()) {
for (const [entryKey, value] of formData.entries()) {
const isFile = value instanceof Blob;
const isField = typeof value === 'string';
let val: FormDataEntryValue | undefined = value;
let val: any
const [key, codecSuffix] = entryKey.split(':')

if (
// empty file
Expand All @@ -55,6 +65,14 @@ export function extractFormData<T = any>(
(isField && value === '')
) {
val = undefined;
} else if (codecSuffix) {
const codec = codecsMapping[codecSuffix] as Codec<any>
if (!codec) {
throw new Error(`No codec found for suffix: ${codecSuffix}`)
}
val = codec.decodeValue(value)
} else {
val = value
}

set(data, key, val);
Expand Down
21 changes: 21 additions & 0 deletions src/fields.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from 'vitest';
import { asBool, asNum, asStr } from './codecs';
import { fields } from './fields';

test('fields', () => {
Expand All @@ -22,3 +23,23 @@ test('fields', () => {
'arrayNested[2].date',
);
});

test('fields with codec', () => {
type Data = {
object?: {
key: string;
};
isActive: boolean;
arrayNested: Array<{
id: number;
}>;
};

const f = fields<Data>();

expect(asBool(f.isActive), 'nested object').toBe('isActive:boolean');
expect(asStr(f.object.key), 'nested object').toBe('object.key:string');
expect(asNum(f.arrayNested(2).id), 'nested array').toBe(
'arrayNested[2].id:number',
);
})
27 changes: 18 additions & 9 deletions src/fields.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
type ReplaceValue<T, V> = T extends Record<string, unknown>
type ReplaceValue<T> = T extends Record<string, unknown>
? {
[K in keyof T]: ReplaceValue<T[K], V>;
[K in keyof T]: ReplaceValue<T[K]>;
}
: T extends Array<infer A>
? Array<ReplaceValue<A, V>>
: V;
? Array<ReplaceValue<A>>
: T extends FieldName<infer V>
? FieldName<V>
: FieldName<T>;

type FieldName = string & {
const fieldTypeBrand: unique symbol = Symbol();

export interface FieldName<T> {
[Symbol.toPrimitive]: () => string;
[fieldTypeBrand]: T
toString(): string;
valueOf(): string;
};

type Field<T> = {
export type Field<T> = {
[K in keyof T]: T[K] extends unknown[]
? T[K][number] extends FormDataEntryValue
? (index?: number) => FieldName
? (index?: number) => FieldName<T[K][number]>
: T[K][number] extends FieldName<infer V>
? (index?: number) => FieldName<V>
: (index: number) => Field<T[K][number]>
: T[K] extends FormDataEntryValue
? FieldName
? FieldName<T[K]>
: T[K] extends FieldName<infer V>
? FieldName<V>
: Field<T[K]>;
};

Expand All @@ -30,7 +39,7 @@ type DeepRequired<T> = T extends Array<infer U>
}
: Exclude<T, undefined | null>;

type Fields<T> = Field<ReplaceValue<DeepRequired<T>, FieldName>>;
type Fields<T> = Field<ReplaceValue<DeepRequired<T>>>;

export function fields<T>(): Fields<T> {
return new Proxy(
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { fields } from './fields';
export { asNum, asBool, asStr, Codec } from "./codecs"
export { extractFormData } from './extract_form';
1 change: 1 addition & 0 deletions src/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function set(obj: any, path: string, value: unknown) {
type ParsedSegment =
| { type: 'key'; key: string }
| { type: 'index'; key: number };

function parsePath(str: string) {
const parsed: ParsedSegment[] = [];

Expand Down