While writing code to query a database using Supabase, I became curious about the exact moment an asynchronous request is made. It felt different from my usual experience using Promise objects.
Generally, the point at which a Promise object is created and the point at which
the asynchronous request begins are the same. Think about using Workspace
or
setTimeout
. For example, the delay
function below, created using
setTimeout
, starts its asynchronous operation immediately when the Promise
object is created.
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
delay(1000); // Starts counting 1 second right here, regardless of await
However, with Supabase, the asynchronous request isn’t made until await
is
used. Thanks to this characteristic, you can conditionally add clauses to the
query and then initiate the asynchronous request with await
once the query is
fully constructed.
let query = supabase.from('...').select('...');
if (tagIds) query = query.in('id', tagIds);
const { data, error } = await query; // The request happens here
Another peculiar point is that even if you use await
multiple times on the
same builder object (after modifying it), it correctly sends requests
corresponding to the query’s state at that moment.
let query = supabase.from('...').select('...');
let { data, error } = await query; // ✅ Request 1
query = query.order('created_at', { ascending: true });
{ data, error } = await query; // ✅ Request 2 with ordering applied
If you look at the types of methods like select
or order
, they return custom
objects like PostgrestTransformBuilder
or PostgrestFilterBuilder
, not
Promises. I understand they utilized the
Builder pattern to construct
queries step-by-step, but how can you await
an object that isn’t a Promise?
Checking the
Supabase source code,
both PostgrestTransformBuilder
and PostgrestFilterBuilder
are classes that
extend (extends
) PostgrestBuilder
. And PostgrestBuilder
implements
(implements
) PromiseLike
.
export default abstract class PostgrestBuilder<
Result,
ThrowOnError extends boolean = false,
> implements
PromiseLike<
ThrowOnError extends true
? PostgrestResponseSuccess<Result>
: PostgrestSingleResponse<Result>
> {
// ...
}
Looking at the
TypeScript source code,
PromiseLike
refers to 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>;
}
Therefore, PostgrestBuilder
must implement the then
method. Let’s examine
it.
// Some code omitted
export default abstract class PostgrestBuilder {
constructor(builder: PostgrestBuilder) {
//...
if (builder.fetch) {
this.fetch = builder.fetch;
} else if (typeof fetch === 'undefined') {
// Use node-fetch in Node.js environments
this.fetch = nodeFetch;
} else {
// Use the global fetch in browser environments
this.fetch = fetch;
}
}
then<TResult1 = Result, TResult2 = never>(
onfulfilled?:
| ((
value: ThrowOnError extends true
? PostgrestResponseSuccess<Result>
: PostgrestSingleResponse<Result>,
) => TResult1 | PromiseLike<TResult1>)
| undefined
| null,
onrejected?:
| ((reason: any) => TResult2 | PromiseLike<TResult2>)
| undefined
| null,
): PromiseLike<TResult1 | TResult2> {
// Add schema profile header if needed
// ...
const _fetch = this.fetch; // Use the fetch determined in the constructor
// Perform the actual fetch request
let res = _fetch(this.url.toString(), {
method: this.method,
headers: this.headers,
body: JSON.stringify(this.body),
signal: this.signal,
}).then(async (res) => {
// Process the response...
// ...
});
// More response processing...
// ...
// Chain the user's callbacks
return res.then(onfulfilled, onrejected);
}
}
We can confirm that the actual network request is made within the then
method.
It’s also quite impressive how the constructor sets up the appropriate
Workspace
function depending on the environment where Supabase is being used.
I need to abandon the fixed idea that the target of await
must always be a
Promise object. The code below works perfectly fine:
const obj = {
then(resolve, reject) {
// This object behaves like a Promise because it has a 'then' method
console.log('then method called by await');
resolve('hello from thenable');
},
};
async function test() {
console.log('Before await');
const res = await obj; // 'await' calls the 'then' method
console.log('After await:', res);
}
test();
// Output:
// Before await
// then method called by await
// After await: hello from thenable
There’s also relevant information on the MDN Promise page, which I’ll quote here:
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. For example, Promise.resolve will not only resolve promises, but also trace thenables.
This explains how await
(which essentially works with Promises) can operate on
objects like Supabase’s query builders, as long as they implement the then
method, making them “thenable” or PromiseLike
.
You need to login to leave a comment.