Introduce standard Symbols for Fetch API primitives

Goal

Introduce new standard Symbols to denote the identity of Fetch API primitives:

  • Symbol.Request
  • Symbol.Response
  • Symbol.Headers

Motivation

The current Fetch API specification doesn’t define any criteria to use for the validation of Fetch API primitives: how can I be sure that a given object is a valid Request per Fetch API specification? While it’s possible to assert that in the browser and in the latest version of Node via X instanceof Request, that is not an option in order Node versions and other environments where fetch is unavailable for one reason or another. Moreover, even when the entire ecosystem migrates to have fetch() available everywhere, that doesn’t mean there will be no room for polyfills.

My reasoning comes from an open-source maintainer’s point of view. I maintain a library that consumes Fetch API primitives defined by the library user. Since the user may be relying on global fetch, or they may be relying on a fetch polyfill, it becomes challenging, if not virtually impossible, to find a common ground for such a variable input. This manifests in the fact that despite every fetch polyfill having the intention to implement the specification (some to a bigger extent than others) , all polyfills lack the validity (and identity) of their Request/Response/etc. polyfill classes behind internal symbols or class inheritance. This makes it impossible to provide, let’s say, a Request instance created by one polyfill to be consumed by another.

I do acknowledge that this is rather an edge case. But as a person who’s been working with this for half a decade now, I would very much appreciate a standardized way to denote Fetch API primitives so that, hopefully, polyfill implementations and other packages that manipulate, consume, or create such primitives could coexist and cooperate better.

Implementation example

This proposal advocates for the introduction of new internal Symbols that would clearly denote Fetch API primitives, such as Request, Response, and Headers instances. For example:

function isValidRequest(input) {
  return Symbol.Request in input
}

I advocate for using Symbols vs other methods due to their exclusive and explicit nature. The primitives’ validity logic cannot truly be substituted by any other means at the moment, as none of those means would be reliable or explicit on the definition side.

// Relying on the string representation of the primitives
// is not a valid approach.
input.toString() // "[object Request]"

// Inheritance will only be true if the "input" and the
// "Request" were created from the same source. That's not
// the case if you're consuming an unknown polyfill,
// as the nature of "input" becomes dynamic but your, as the
// library author, must rely on a *single* Request class for
// this comparison.
input instanceof Request

// Checking specific properties and methods is brittle
// and leads to false positive matches quite easily.
'method' in input && 'url' in input

Since I’ve heard some share of criticism toward the standard Symbols in JavaScript, I may alternatively propose to introduce a single Symbol that would act as the denominator for the Fetch API primitives’ validity. For example:

input[Symbol.toFetchPrimitive] === 'Request'

Alternatively, any appropriate existing Symbol can be reused to achieve the same goal:

input[Symbol.species] === "FetchRequest'

Though, I do acknowledge that the current state of standard Symbols’ descriptiveness is an implicit indicator that it’s okay to have specific purpose-driven symbols. Thus, we have things like Symbol.split and Symbol.isConcatSpreadable that are extremely specific.

Benefits

  • If the implementation includes the aforementioned new standard Symbol(s), it explicitly marks a respective object as a valid Fetch API primitive. This creates a conscious contract that the implementation author follows, preventing false positive matches and ensuring specification compliance.

The fetch API is not part of JavaScript; it's part of HTML, and it wouldn't be appropriate to put Symbols for it on Symbol.

You may want to discuss his on a WHATWG repo.

2 Likes

Thanks for a swift response on this, @ljharb! Makes sense, I will open a new proposal with WHATWG.

More importantly, you yourself say:

While it’s possible to assert that in the browser and in the latest version of Node via X instanceof Request , that is not an option in order Node versions and other environments where fetch is unavailable for one reason or another

Older versions of Node won't have the new symbols, and newer versions don't need the new symbols. I don't understand what value the new symbols would bring, then.

They would serve as an explicit way to say "hey, this object is a valid Request/Responst/etc". I'm aiming at making the work with polyfills a bit better. I acknowledge this will become less and less of an issue as time goes on and the global fetch adoption increases with the modern versions of Node.

I wish I raised this sooner. The fetch support in Node is great but it will take time until the entire ecosystem gets there.

But it makes sense what you say, I didn't take into account how the older versions of Node would get this symbol.

As long as they support ES6, they can use the Symbol, regardless of whether it's exposed globally on the Symbol object

My point is that older versions of Node won't expose the new symbol, because they're old and thus predate the new symbol. You can't get around something not being implemented by specifying something new to implement.

1 Like

It doesn't matter if Node exposes the Symbol as long as it supports Symbols. Node has fully supported them since version 0.12, and considering support for those versions ended 4 years ago, I doubt this would be an issue for anyone.

I'm also fairly confused at how this would handle anything.

In the case of an outdated node enviornment that has a single, up-to-date fetch polyfill on it, then both "Symbol.Request in input" and "input instanceof Request" would work, since there's a single polyfill installed on the system, and all request objects would be an instance of that polyfill.

In the case of two different polyfills on the same system - for that to be possible, these polyfills must not actually be installed on the global object, otherwise there would be a conflict and only one polyfill would win. If these polyfills are not putting fetch onto the global object, I must assume that they also wouldn't attaching a Request symbol onto the Symbol class for the same reason (and, instead, you'd have to import this new symbol from the polyfill). If this happens, then there would be two different Request objects, and there would be two different symbols, which means neither Symbol.Request in input nor input instanceof Request would work.

True. That's why I mentioned that this is rather an edge case.

My motivation here is to be able to determine Fetch primitives' validity in an ambiguous context. Imagine writing a package that operates on Fetch API requests. So it may look something like this:

function isValidRequest(request) {}

Now, since it's the consume who's supplying the request, it can be either a global fetch or a fetch polyfill. Despite those two implementing the same specification, they won't be runtime-compatible. Fetch polyfills lock the identity of fetch primitives under internal symbols (I tend to believe for the lack of the better means), which means that even if you provide a polyfill with a valid Request implementation (e.g. from another polyfill) it will reject it.

I find that to be against the purpose of having a specification. And this ambiguous context is possible if your library also need to construct a Request or Headers. It cannot possible know what polyfill the consumer would use, if any. So the library installs a fixed, known polyfill. Suddenly, provided Request instances are not compatible with that internal library's polyfill.

That's where having a standardized Symbol seems like an idea worth exploring. I hope I brought some more light over the context of this proposal.

I think I understand the problem you're facing, I'm just not understanding the proposed solution. Perhaps a concrete example would help illustrate the issue?

// fetch-polyfill-a.js

export function fetch() { ... }

export class Request { ... }

export const RequestSymbol = Symbol('Request');

export function installOnGlobal() {
  if (!globalThis.fetch) globalThis.fetch = fetch;
  if (!globalThis.Request) globalThis.Request = Request;
  if (!globalThis.Symbol.Request) globalThis.Symbol.Request = RequestSymbol;
}

// fetch-polyfill-b.js
// (it's exactly the same as fetch-polyfill-a.js, so don't bother looking too closely)

export function fetch() { ... }

export class Request { ... }

export const RequestSymbol = Symbol('Request');

export function installOnGlobal() {
  if (!globalThis.fetch) globalThis.fetch = fetch;
  if (!globalThis.Request) globalThis.Request = Request;
  if (!globalThis.Symbol.Request) globalThis.Symbol.Request = RequestSymbol;
}

// yourLibrary.js

export function doFancyStuff(requestObj) {
  if (!(Symbol.Request in requestObj)) {
    throw new Error('You must supply a valid request object');
  }
  ...
}

// main.js

import * as fetchPolyfillA from './fetch-polyfill-a.js';
import * as fetchPolyfillB from './fetch-polyfill-b.js';
import { doFancyStuff } from './yourLibrary.js';

// We only install one of these on the global object, otherwise we get conflicts.
fetchPolyfillA.installOnGlobal();

doFancyStuff(new fetchPolyfillB.Request(...)); // Error: "You must supply a valid request object"

Here, I have an example of two polyfills, a library like yours, and a main script that's using all of them. We can see that, even if we had a new special symbol, there still would be the same issues because there's two versions of that symbol.

I think that currently, the best solution a library author can do in a scenario like this, is to, by default, try to use whatever Request object is found on globalThis with instanceof checks. If a user wishes to use a polyfilled Request object that's not found on globalThis, they'll need to register this polyfilled Request class with the library first. This is commonly how libraries handle the various promise APIs that are out there - they, by default, use the built-in promise API, but if you, instead, wish to use bluebird promises or something, you have to register the bluebird promises with the library, and then it'll start using those instead (it's a different problem being solved, but the same solution could be used here)

Hi,
I don't think introducing new well-known intrinsic symbols would pose a barrier to polyfills. However, I don't quite understand the problem either

Why would you care whether it's "valid"? All that matters is that you can pass it to fetch(), no? And for that, duck typing is a better solution than putting special symbols on those objects (which can be faked as well). As you say, the goal is that you can manipulate, consume and create Request objects. A symbol doesn't really help with that. What helps is the standard property names that are adhered by all implementations - an indeed, the following should work just fine, since fetch and the Request constructor accept other requests and request-like objects:

const a = new packageA.Request({…});
const b = new packageB.Request(a, {…});
const r = await packageC.fetch(b);

Okay, let me provide you with a better example then.

Criteria: write a function that accepts a Request instance and sets its headers property to a Headers instance if it's not set already. The function itself doesn't create the Request instance.

function act(request) {
  if (typeof request.headers === 'undefined') {
    request.headers = new Headers({
      'X-Example': 'yes'
    })
  }
}

The challenge here is that both the Request and the Headers instance must come from the same source: either the same polyfill or the same global references.

Imagine a polyfill scenario: a consumer of the act function is using a polyfill, so the passed Request instance is an object created by that polyfill. The act function cannot predict what polyfill is going to be used. If it's going to construct a global Headers object and set it on request.headers, the polyfill will not treat that object as a Headers instance because that only yields true if a given object has a specific internal symbol of the polyfill (i.e. any polyfill only treats fetch primitives as valid if they have been created by the same polyfill, regardless if they actually fulfill the Fetch API specification or not).

Once again, this is an edge case. And yet, it's a totally reasonable functionality to have in libraries that deal with request/response transformations in ambiguous contexts.

I don't believe you can correctly change any passed request instance unless the library and the consumer use the same polyfill (impossible to know, the library doesn't and mustn't enforce any particular polyfill) or use the same global fetch (impossible to achieve when promising Node < 17 support).

Symbols won't help you if a polyfill doesn't choose to use one.

In this case, it's the polyfill's responsibility - not yours - to ensure that its types and identities match that of the global, when it exists, as much as possible. If a Request exists in the env, then the polyfill needs to use it, otherwise, it's not a proper polyfill and your function shouldn't work with it.

Of course. That's why if there was a Symbol I could encourage polyfill maintainers to adopt it.

Not a single commonly used Fetch polyfill concerns with validating its primitives against any globals. That's precisely my complain: there's no standardized way to say "hey, this object is a valid Fetch API Request object" because there's no definition of what a valid Request object is. So polyfills assume that the only valid object is the object that they create. That is not the case if you work with complex tooling.

It's also too optimistic to assume that polyfilled primitives will be set to globals. That's up to the consumer of the polyfill and that's a good thing. You can import polyfill classes and then, if you want, set them globally.

Given the sheer explanation of the problematics is too challenging, perhaps it's worth waiting a few months before older versions of Node will get officially discontinued and then the global fetch will be a common thing.

Then I'd suggest using those advocacy powers to encourage the polyfills to match the globals whenever possible :-)

I totally agree that polyfills shouldn't automatically install themselves on the global, but they can still do class Request extends globalThis.Request when it's available.

Isn't the purpose of a polyfill is to provide the otherwise missing functionality?
If the global exists in polyfill's context in order to extend it, then why use a polyfill in the first place?

a) the global one might be broken (they often are, in subtle ways)
b) the exact use case you mentioned - wanting to support envs both with and without the builtin

2 Likes

The B option is precisely what I went with: using the global primitives if present, otherwise falling back to a fixed polyfill used internally by the library (which is causing troubles due to incompatibility with other poltyfills as I've mentioned).

Appreciate the discussion around this!