npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

ts-formdata

v1.0.0

Published

Add types to your FormData!

Downloads

14

Readme

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