ts-formdata
TypeScript icon, indicating that this package has built-in type declarations

1.0.0 • Public • Published

ts-formdata

npm GitHub top language GitHub Workflow Status (with branch)

FormData is a convenient way to extract data from forms - however it is not so convenient to use when you want the form payload to be something more complex than key-value pairs. In addition, the values are always strings- and you are responsible to converting them to numbers, dates, booleans etc.

Writing these converters are boring and error-prone - this library helps you by providing you a simple approach to encode nesting and type information in the field names so that they can be easily parsed into nested structures in a type-safe manner.

ts-formdata is framework agnostic - feel free to use it alongside Vanilla JS, React, Svelte etc. on client side or with formdata-node on the server side.

Installation

# npm
npm i ts-formdata

# pnpm

pnpm i ts-formdata
# yarn
yarn add ts-formdata

Usage

Let's say our form data looks like this:

type UserPayload = {
    settings: {
        mode: 'auto' | 'light' | 'dark';
        theme: 'red' | 'green' | 'blue';
    };
    favouriteFrameworks: Array<{
        name: string;
        satisfaction: number;
    }>;
    profile: {
        firstname?: string;
        lastname?: string;
        image?: Blob;
    };
};

Fields

Use the fields helper to create your input names:

import React from 'react';
import { fields, asStr, asNum } from 'ts-formdata';

const f = fields<MyForm>();

// We are using React for illustration here, but ts-formdata
// is framework agnostic
const UserPayloadForm = () => {
    return (
        <form
            onSubmit={(e) => {
                const formData = new FormData(e.currentTarget);
                const {
                    data, // files and fields
                    fields, // fields only
                    files, // files only
                } = extractFormData<MyForm>(formData);
                console.log('data: ', data);
            }}
        >
            <h2>Settings</h2>

            <label>
                <span>Mode</span>
                <select name={asStr(f.settings.mode)}>
                    <options>auto</options>
                    <options>light</options>
                    <options>dark</options>
                </select>
            </label>
            <label>
                <span>Theme</span>
                <select name={asStr(f.settings.theme)}>
                    <options>red</options>
                    <options>green</options>
                    <options>blue</options>
                </select>
            </label>

            <h2>3 favourite Frameworks</h2>

            <input type="text" name={asStr(f.favouriteFrameworks[0].name)} />
            <input
                type="number"
                name={asNum(f.favouriteFrameworks[0].satisfaction)}
            />

            <input type="text" name={asStr(f.favouriteFrameworks[1].name)} />
            <input
                type="number"
                name={asNum(f.favouriteFrameworks[1].satisfaction)}
            />

            <h2>User</h2>

            <input type="text" name={asStr(f.profile.firstname)} />
            <input type="text" name={asStr(f.profile.lastname)} />
            <input type="file" name={asStr(f.profile.image)} />
        </form>
    );
};

What is happening under the hood is:

  • fields() returns a proxy that will create a string
    • So: f.user.lastname.toString() will be a string "user.lastname"
  • asStr, asNum etc. are encoders that append type information to this string
    • So: asStr(f.user.lastname) resolves to a string "user.lastname:string"
  • We use this string as a name of the field.
  • Our extractFormData function is aware of this path syntax and type suffix and is able to derive the nested structure with appropriately coerced values from the key-value pairs we get in the FormData object.

Type safety:

  • Attempting to construct paths that don't match the keys in payload (eg. f.setting.mode) will result in a type error.
  • Attempting to use a type encoder with field of different type (eg. asNum(f.user.firstname)) is also a type error.

Nested paths

For nested objects simply chain the keys:

fields<MyForm>().user.firstname;
> 'user.firstname'

For arrays you have to call the function:

fields<MyForm>().favouriteFrameworks();
> 'favouriteFrameworks[]'

Note: Only use primite values inside arrays if you don´t provide an index!

Bad:

fields<MyForm>().favouriteFrameworks().key;
> 'favouriteFrameworks[].key'

Good:

fields<MyForm>().favouriteFrameworks(1).key;
> 'favouriteFrameworks[1].key'

Simply pass in the index:

fields<MyForm>().favouriteFrameworks(2);
> 'favouriteFrameworks[2]'

It´s also possible to create objects in arrays

fields<MyForm>().favouriteFrameworks(2).satisfaction;
> 'favouriteFrameworks[2].satisfaction'

extractFormData

extractFormData pulls out the data from a FormData object where fields are typed as string and files are typed as Blob:

Note: Browsers send empty inputs as an empty string or file. extractFormData omits the values.

import { extractFormData } from 'ts-formdata';

export async function handlePost(request: Request) {
    const formData = await request.formData();

    const {
        data, // files and fields
        fields, // fields only
        files, // files only
    } = extractFormData<MyForm>(formData);

    // data:
    type Data = {
        settings?: {
            mode?: string;
            theme?: string;
        };
        favouriteFrameworks?: Array<{
            name?: string;
            satisfaction?: string;
        }>;
        user: {
            firstname?: string;
            lastname?: string;
            image?: Blob;
        };
    };

    // fields:
    type Fields = {
        settings?: {
            mode?: string;
            theme?: string;
        };
        favouriteFrameworks?: Array<{
            name?: string;
            satisfaction?: string;
        }>;
        user: {
            firstname?: string;
            lastname?: string;
        };
    };

    // files:
    type Files = {
        user: {
            image?: Blob;
        };
    };
}

Custom Codecs

If the provided converters asStr, asNum etc. are not adequate for you, you can provide custom codecs to the library which will be used for encoding of names and extraction of values.

For example if you want boolean values to be represented as 1/0 values, you can implement a codec as follows:

import { BaseCodec } from "ts-formdata";

export class ShortBoolCodec extends BaseCodec<boolean> {
    constructor() {
        super('sbool') // <-- Type suffix used in name
    }
    decodeValue(value: FormDataEntryValue) {
        return value === '1';
    }
}

const shortBool = new ShortBoolCodec();

Now in your form:

<input type="text" pattern="(1|0)" name={shortBool.encode(f.mood.isHappy)} />

While extracting you need to pass this codec to extractFormData:

extractFormData(new FormData(e.currentTarget), [shortBool])

Multistep wizards

You can merge data from multiple forms by providing an accumulator to extractFormData.

const extracted: Partial<Payload> = {};
extractFormData(formData, [], extracted);
// Later:
extractFormData(formData, [], extracted);

extracted will continue to accumulate the extracted fields in each extractFormData invocation.

This is convenient for example, in wizards where each step needs to be separately validated on step submission but data is saved to server only after the final step.

Caveats

Missing fields

If a certain field is missing in the rendered form, it will not be present in the form data. However we are not able to validate this at compile time.

This is not a big problem in practice because these errors are easily caught during preliminary testing. However, if you do want to safeguard against partial data you will need to use a validation library like zod to validate the extracted data.

You also need to take care to not use conditional rendering for form fields, as only the fields which are currently present in DOM will be extracted. So it is a better practice to structure dynamic forms such that any fields that are hidden from view are hidden through css rather than removed from DOM.

License

MIT

Readme

Keywords

Package Sidebar

Install

npm i ts-formdata

Weekly Downloads

4

Version

1.0.0

License

MIT

Unpacked Size

62.6 kB

Total Files

16

Last publish

Collaborators

  • lorefnon