Seongyeol Yi

Deferring Async Requests with PromiseLike

The Question

The moment a Promise object is created typically coincides with when the async request is made. Think about using fetch or setTimeout. For example, the delay function below using setTimeout starts the async work the instant the Promise is created.

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
delay(1000); // Starts counting 1 second immediately, regardless of await

But with Supabase, no async request is made until you await. This lets you conditionally build a query and only fire the request once it’s complete.

// No request yet
let query = supabase.from("...").select("...");
if (tagIds) query = query.in("id", tagIds);

// Request happens here
const { data, error } = await query;

Another peculiarity: even if you await multiple times, each await sends a fresh request matching the current query state.

let query = supabase.from('...').select('...');

// Executes query with select only
let { data, error } = await query;

query = query.order('created_at', { ascending: true });

// Executes query with select + order
{ data, error } = await query;

The types returned by select or order are custom objects like PostgrestTransformBuilder and PostgrestFilterBuilder, not Promises. It’s clear they’re using the builder pattern for step-by-step query construction, but how can you await an object that isn’t a Promise?

Research

Looking at the Supabase source code, both PostgrestTransformBuilder and PostgrestFilterBuilder extend PostgrestBuilder, which implements PromiseLike.

export default abstract class PostgrestBuilder<
  Result,
  ThrowOnError extends boolean = false,
> implements PromiseLike<
  ThrowOnError extends true
    ? PostgrestResponseSuccess<Result>
    : PostgrestSingleResponse<Result>
> {
  // ...
}

In the TypeScript source code, PromiseLike is defined as an object that implements a then method.

interface PromiseLike<T> {
  /**
   * Attaches callbacks for the resolution and/or rejection of the Promise.
   * @param onfulfilled The callback to execute when the Promise is resolved.
   * @param onrejected The callback to execute when the Promise is rejected.
   * @returns A Promise for the completion of which ever callback is executed.
   */
  then<TResult1 = T, TResult2 = never>(
    onfulfilled?:
      | ((value: T) => TResult1 | PromiseLike<TResult1>)
      | undefined
      | null,
    onrejected?:
      | ((reason: any) => TResult2 | PromiseLike<TResult2>)
      | undefined
      | null,
  ): PromiseLike<TResult1 | TResult2>;
}

Since PostgrestBuilder implements PromiseLike, it must have a then method. Let’s take a look.

export default abstract class PostgrestBuilder {
  ...
  then(onfulfilled?, onrejected?): PromiseLike<TResult1 | TResult2> {
    ...
    const _fetch = this.fetch;
    let res = _fetch(this.url.toString(), {
      method: this.method,
      headers: this.headers,
      body: JSON.stringify(this.body),
      signal: this.signal,
    }).then(async (res) => {
      ...
    });
    ...
    return res.then(onfulfilled, onrejected);
  }
}

The actual network request happens inside the then method.

Conclusion

We should let go of the assumption that everything you await must be a Promise object. The following code works perfectly fine:

const obj = {
  then(resolve, reject) {
    resolve("hello");
  },
};

async function test() {
  const res = await obj;
  console.log(res);
}

test();

MDN’s Promise documentation has a relevant note:

The JavaScript ecosystem had made multiple Promise implementations long before it became part of the language. Despite being represented differently internally, at the minimum, all Promise-like objects implement the Thenable interface. A thenable implements the .then() method, which is called with two callbacks: one for when the promise is fulfilled, one for when it’s rejected. Promises are thenables as well.

To interoperate with the existing Promise implementations, the language allows using thenables in place of promises.

More on Frontend

View all posts