TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

FormData methods should take a Generic

Open wesbos opened this issue 3 years ago • 23 comments

Suggestion

🔍 Search Terms

Form Data, .entries()

✅ Viability Checklist

My suggestion meets these guidelines:

  • [x] This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • [x] This wouldn't change the runtime behavior of existing JavaScript code
  • [x] This could be implemented without emitting different JS based on the types of the expressions
  • [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • [x] This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

FormData entries() method, and perhaps .keys() and .values() should take a generic.

Right now it returns FormDataEntry value and is difficult type type the returned value of that Iterable.

📃 Motivating Example

Example of the problem here:

https://www.typescriptlang.org/play?strictNullChecks=false&target=6#code/PTAEEEGdIVwWwKagIagGYHsBOdQBsBLAayQBcALAyUAgO1AqQBEB5AWQBpQAjGU0AO4EK+ZNwR5qCUgGMAdHIBQMjLUj9MOUAF5QwAFShypOHlD6wAAwA8muAD5FoUNboAHPgwCebhNoBE6lh0AOb+oLTIiAFoBFjq4QQAJjFxCaAAbsh4MH7+AgiQ-o7OrrQe-KQ+eUGh4ZHR-njI6ckBzelZOXncGEWO1sB29pYA3IqKIKAAKuQI9HSgcF4zAMoAXBN0pAhYaMgySAAKu5CqoADeTuhppOugtbQh484ddw+kwU-jAL4TaDBaDJSARzpBkF4ABIEAAUvniqnuJwRtAAlJdrlhpDAsPRLJCECsACQXeFnWhyWLxUg-MaKP7KVTqdDYOAAUTMuiSGBk8HmpDkAEdclgvKsJAhgdgYQByOwy1HjFRqDSspjIUioXS0BACUAAMTVGuQMLsHMVkzAAGFVBldpVyBqHjAZCQsAwMCgFjssGI8AhGSrQEljQBJH1+pC6OzqzVyfnBQowi1TG20O1YB1O4S7SMer2gDDcABWkoFlpmc1AbiwRf9uBUiGocyxXHIGD13NAoaWyBIgkKKFAyPJ3l8AH5QIHmQVqLoWCWy5Ta+zaJ8CEmQ5rw7nuP6LeCobDZxaK7MqMGMPNIDL+AJsERp3fCgAmHSgBel4HLjCr9ebsMIz3BB0RaYdTlUcYz0oahuUKLheH4C9hELIgIUncUEDgahYkgcgvDkKdlRnQoAGZ30-Jc0BXNk10TSAYS3ZAd19YDQOoQEiFoDt6DAkdIMUQ9oRhWdSNPCsWEYd1ZgwGAQmMSB7grABGQjzykAAPKI3H9GhqEgAg4B0hAuB7FQYDwJJe1oGBsjwFYZCxDUyCrIsv1IBDPAoOgiBQXpPGaLAQl2QtF2BSAgA

Feeding the .entries() Iterable into Object.fromEntries(), gives us a type of { [k: string]: FormDataEntryValue; }, and has no overlap between the acutal data (Person in my case) that is supposed to come back.

So perhaps something like formData.entries<Person>() or formData.entries<IterableIterator<Person>>()?

I just know it's really hard to convert the result into a type. And the solutions are either as unknown as Person or a Type Guard, which makes me manually type each property.

💻 Use Cases

See above link example

wesbos avatar Apr 23 '21 20:04 wesbos

How about making FormData itself generic?

So we can do something like this:

const formData: FormData<Person> = new FormData(formEl);

max-hk avatar May 13 '21 18:05 max-hk

I am looking forward to it, it is very useful to me.

axetroy avatar Aug 23 '21 13:08 axetroy

Here is my solution: define a custom FormData

interface IRuntimeForm {
  [key: string]: any;
}

export class RuntimeForm<T extends IRuntimeForm> {
  constructor(private _form: T) {}
  public formData(): FormData {
    const form = new FormData();

    for (const key in this._form) {
      if (this._form[key] !== undefined) {
        form.append(key, this._form[key]);
      }
    }

    return form;
  }
}

new RuntimeForm<{ foo: string }>({ foo: "bar" })

axetroy avatar Aug 23 '21 17:08 axetroy

How about making FormData itself generic?

So we can do something like this:

const formData: FormData<Person> = new FormData(formEl);

yes that'd be great

sambitevidev avatar Sep 30 '21 10:09 sambitevidev

Here is my solution: define a custom FormData

interface IRuntimeForm {
  [key: string]: any;
}

export class RuntimeForm<T extends IRuntimeForm> {
  constructor(private _form: T) {}
  public formData(): FormData {
    const form = new FormData();

    for (const key in this._form) {
      if (this._form[key] !== undefined) {
        form.append(key, this._form[key]);
      }
    }

    return form;
  }
}

new RuntimeForm<{ foo: string }>({ foo: "bar" })

thanks, nice temp solution. generic form data would be a great feature still.

cricketnest avatar Oct 03 '21 17:10 cricketnest

export class RuntimeForm<T extends IRuntimeForm> {
  constructor(private form: T) {}

  public formData(): FormData {
    const form = new FormData();

    Object.keys(this.form).forEach((key) => {
      if (this.form[key] !== undefined) {
        form.append(key, this.form[key])
      }
    })

    return form;
  }
}

If anyone is using AirBnB rules and wants the above to work

ZinkNotTheMetal avatar Feb 22 '22 14:02 ZinkNotTheMetal

Just wanted to note that this'd be a very welcome feature, especially as some newer frameworks like Remix put FormData front and center.

RyKilleen avatar Mar 08 '22 17:03 RyKilleen

I started using FormData a lot recently in a full-stack project and it's painful to use something not typed. This would be really handy.

JohnCido avatar Apr 18 '22 08:04 JohnCido

  • 1

chandlervdw avatar Apr 19 '22 14:04 chandlervdw

+1 on this suggestion. Remix's reliance on FormData is making this much more important

filipemir avatar Jul 06 '22 13:07 filipemir

+1

viktor-ulyankin avatar Aug 03 '22 14:08 viktor-ulyankin

+1

knenkne avatar Aug 05 '22 00:08 knenkne

+1

DigitalNaut avatar Aug 22 '22 20:08 DigitalNaut

+1

altmshfkgudtjr avatar Aug 26 '22 08:08 altmshfkgudtjr

+1

fairnakub avatar Aug 31 '22 16:08 fairnakub

+1. I was trying to correct the type of a FormData.fromEntries and this was the first thing I tried, just to arrive here after looking around how to solve this.

lmarcarini avatar Sep 15 '22 12:09 lmarcarini

+1, this would be very useful on Remix and new react router based projects

gggiovanny avatar Sep 25 '22 04:09 gggiovanny

+1

zorahrel avatar Oct 16 '22 13:10 zorahrel

+1, same using remix.

BensThoughts avatar Oct 16 '22 17:10 BensThoughts

Yes please +1

pete-willard avatar Oct 17 '22 09:10 pete-willard

+1

alojzy231 avatar Oct 17 '22 11:10 alojzy231

+1

mohamedanwer123 avatar Oct 18 '22 10:10 mohamedanwer123

Please stop with the +1's. Everyone subscribed to this issue gets a notification for it.

Use the emoji reactions on the first post instead.

cdeutsch avatar Oct 18 '22 13:10 cdeutsch

Any news on this ?

DrakkoFire avatar Nov 11 '22 12:11 DrakkoFire

This is what I ended up doing:

type TypedFormDataValue = FormDataEntryValue | Blob

/**
 * Polyfill for FormData Generic
 *
 * {@link https://github.com/microsoft/TypeScript/issues/43797}
 * {@link https://xhr.spec.whatwg.org/#interface-formdata}
 */
interface TypedFormData<T extends Record<string, TypedFormDataValue>> {
  /**
   * Appends a new value onto an existing key inside a FormData object, or adds the key if
   * it does not already exist.
   *
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/FormData#formdata.append}
   */
  append<K extends keyof T>(name: K, value: T[K], fileName?: string): void

  /**
   * Deletes a key/value pair from a FormData object.
   *
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/FormData#formdata.delete}
   */
  delete(name: keyof T): void

  /**
   * Returns an iterator allowing to go through all key/value pairs contained in this object.
   *
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/FormData#formdata.entries}
   */
  entries<K extends keyof T>(): IterableIterator<[K, T[K]]>

  /**
   * Returns the first value associated with a given key from within a FormData object.
   *
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/FormData#formdata.get}
   */
  get<K extends keyof T>(name: K): T[K] | null

  /**
   * Returns an array of all the values associated with a given key from within a FormData.
   *
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/FormData#formdata.getall}
   */
  getAll<K extends keyof T>(name: K): Array<T[K]>

  /**
   * Returns a boolean stating whether a FormData object contains a certain key.
   *
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/FormData#formdata.has}
   */
  has(name: keyof T): boolean

  /**
   * Returns an iterator allowing to go through all keys of the key/value pairs contained in
   * this object.
   *
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/FormData#formdata.keys}
   */
  keys(): IterableIterator<keyof T>

  /**
   * Sets a new value for an existing key inside a FormData object, or adds the key/value
   * if it does not already exist.
   *
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/FormData#formdata.set}
   */
  set(name: keyof T, value: TypedFormDataValue, fileName?: string): void

  /**
   * Returns an iterator allowing to go through all values contained in this object.
   *
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/FormData#formdata.values}
   */
  values(): IterableIterator<T[keyof T]>

  forEach<K extends keyof T>(
    callbackfn: (value: T[K], key: K, parent: TypedFormData<T>) => void,
    thisArg?: unknown,
  ): void
}

function getTypedFormData<T extends Record<string, TypedFormDataValue>>(
  form?: HTMLFormElement | null,
): TypedFormData<T> {
  return new FormData(form || undefined) as unknown as TypedFormData<T>
}

elving avatar Nov 11 '22 12:11 elving

Pretty convenient, thanks @elving :)

DrakkoFire avatar Nov 11 '22 12:11 DrakkoFire

I asked for a similar feature for Response and Request interfaces to accept generic type parameters: https://github.com/microsoft/TypeScript/issues/52777

karlhorky avatar Feb 15 '23 13:02 karlhorky

Additional use case

A typed API client function would be a use case for this:

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

type Options<Method> = {
  method?: Method;
}

export async function fetchApi<Path extends string, Method extends HttpMethod>(
  path: Path,
  options: Options<Method> & {
    formData: `${Method} ${Path}` extends `POST /exercise-checks`
      ? { id: string, file: File } // 👈 Could be FormData<{ id: string, file: File }>
      : `${Method} ${Path}` extends `PUT /exercise-checks/${number}`
      ? { file: File } // 👈 Could be FormData<{ file: File }>
      : never
  }): Promise<
    `${Method} ${Path}` extends `POST /exercise-checks`
      ? { id: string } | { errors: string[] }
      : `${Method} ${Path}` extends `PUT /exercise-checks/${number}`
      ? { id: string } | { errors: string[] }
      : never
  > {
  return '' as any;
}

const file = new File([''], '');
const goodFormData = { id: '1', file: file }; // 👈 Could be new FormData() + .append
const badFormData = { incorrectProperty: false }; // 👈 Could be new FormData() + .append

// ✅ No errors
await fetchApi('/exercise-checks', { method: 'POST', formData: goodFormData })
// ✅ No errors
await fetchApi('/exercise-checks/1', { method: 'PUT', formData: { file: file } })
// ✅ Errors, incorrect method
await fetchApi('/exercise-checks/1', { method: 'PUTzzzzz', formData: { file: file } })
// ✅ Error, incorrect path
await fetchApi('/xxxxx', { method: 'PUT', formData: { file: file } })
// ✅ Error, incorrect form data
await fetchApi('/exercise-checks/1', { method: 'POST', formData: badFormData })

TypeScript Playground

karlhorky avatar Feb 20 '23 14:02 karlhorky

+1

twicer-is-coder avatar Sep 24 '23 19:09 twicer-is-coder

+1

adophilus avatar Oct 14 '23 13:10 adophilus