Manual Async

Async

elements man async Read as markdown

Elements propagates async and await up the call stack for the functions that support it: sql, tx, the others listed below, and calls to your RPC functions. You write those calls without await, the compiler awaits them, and every function that calls one becomes async. Everything else is ordinary TypeScript, and you write async and await for it as usual.

Exactly which calls are awaited is decided by the @redirect build tag. sql is a synchronous function whose @redirect tag points to sqlAsync, an async function. A call to a synchronous function that redirects to an async function is rewritten to await the async function, the function making the call becomes async, and so does every function that calls it, up the call stack. A @redirect to a function that is not async only changes which function is called, and nothing is awaited.

Why

Requiring await in front of every call is a leaky abstraction. In most languages you call a function and get its result, and the library handles its own concurrency underneath, with threads or otherwise. await at every call site pushes that implementation detail into your code, and into every function above it.

Elements inverts it. You call sql() and get the result, which is what you expect. Under the hood the compiler still emits a real await, so Node.js handles the I/O concurrently and nothing blocks while the query runs. When you want to run several things at the same time, you say so by calling the async variant, such as sqlAsync(), and passing the promises to Promise.all.

At a Glance

You write this:

app/lib/users.ts
import { sql } from "@elements/app"; export function findUser(id: string) { return sql<User>(` select * from users where id = $1 `, [id]).firstOrThrow("no user"); } export function greet(id: string) { let user = findUser(id); return `hello ${user.name}`; }

The compiler emits this:

Compiled output
async function findUser(id) { return await sqlAsync(...).firstOrThrow("no user"); } async function greet(id) { let user = await findUser(id); return `hello ${user.name}`; }

findUser became async because sql redirects to sqlAsync, which is async. greet became async because it calls findUser.

How the Build Tag Works

Here is the redirect tag on sql, in @elements/app:

Redirect tag
/** * @redirect to=sqlAsync */ declare function sql<T = any>(text: string, args?: unknown[]): SqlResult<T>; declare function sqlAsync<T = any>(text: string, args?: unknown[]): Promise<SqlResult<T>>;

@redirect to=sqlAsync says: when someone calls sql, call sqlAsync instead. Because sqlAsync is async, declared async or returning a Promise, the call becomes await sqlAsync(...) and its caller becomes async. The synchronous sql is the signature you write against. sqlAsync is what actually runs.

How To Tell Which Functions Participate

A call is awaited for you if, and only if, the function you call carries a @redirect tag and the function the tag names is async: declared async, or returning a Promise. Calls to your own RPC functions are awaited too (see below), and so is any function that becomes async because it makes one of these calls. A function you marked async yourself never participates: see "A function you marked async yourself" below. The tag ships in the package's type declarations, so you never have to guess:

Terminal
grep -B3 "@redirect" node_modules/@elements/app/server/index.d.ts

Your editor shows the same thing: hover the function and the JSDoc includes the tag. If the declaration has a @redirect to an async function, the compiler awaits the call. If it does not, the function is ordinary TypeScript and the usual rules apply.

These are the functions in @elements/app that redirect to an async function today. Each has a counterpart with the same name and an Async suffix, except where noted.

Database, from @elements/app:

Database
sql tx checkoutDb

Methods on a Database or DatabasePool:

Database methods
connect end checkout begin commit rollback sql tx listen notify

Channels, sessions, email, jobs, files:

Channels, sessions, jobs
channel.listen channel.notify listener.stop session.login session.logout session.renew session.revoke session.findActiveSessions email email.send job.schedule Job.cancel File.read sleep

App lifecycle: app.start, app.stop, client.start, client.call.

LiveTable, on the server and in the browser:

LiveTable
view insert update delete more

Tests:

Test
test("creates a user", () => { let user = sql<User>(` insert into users ( name ) values ( 'alice' ) returning * `).firstOrThrow(); equal(user.name, "alice"); });

test carries a redirect like everything else, so a test body that queries is awaited correctly without you marking it async. It is the one redirected function with no *Async counterpart you can call: its promise belongs to the test runner. See man tests.

RPC functions are a special case. A function tagged @rpc has no @redirect and no *Async name, but it becomes async on both sides, because calling it from the browser is a network round trip:

RPC function
/** @rpc */ export function createPost(title: string): Post { return sql<Post>(` insert into posts ( title ) values ( $1 ) returning * `, [title]).firstOrThrow(); }

The browser calls createPost(title) and the compiler awaits it there. See man rpc.

Which Name To Write

Two rules decide it, and neither needs judgement.

For a redirected function, write the name that carries the tag. sql, not sqlAsync. The compiler inserts the await. Never write await on a call to a redirected function yourself. If you do, you get "'await' expressions are only allowed within async functions", and the fix is to delete the await, not to mark the function async.

Write the *Async name only when the promise has to be a value in your code: passed to Promise.all or Promise.race, stored in a variable you await later, or returned without awaiting. If the word Promise does not appear and you are not holding an unawaited result, you want the tagged name.

Everything else is plain TypeScript. A function you marked async yourself, a promise-returning library call, a fetch: you write async and await for those exactly as you would anywhere else.

An async body does not change the answer. Marking your function async, or using await on something else inside it, never means you should switch to an *Async name. The suffix is about whether you need the promise, not about what is inside your function.

Choosing a name
let user = findUser(id); // tagged: you want the value let [a, b] = await Promise.all([ // Async: you want the promises sqlAsync<Post>(`select * from posts`), sqlAsync<Comment>(`select * from comments`), ]);

Calling the Async Version Yourself

Every redirected function has its *Async counterpart exported, with one exception: test. Call the counterpart directly when you want control, for example to run queries concurrently:

Concurrent queries
export async function loadDashboard(userId: string) { let [posts, comments] = await Promise.all([ sqlAsync<Post>(` select * from posts where user_id = $1 `, [userId]), sqlAsync<Comment>(` select * from comments where user_id = $1 `, [userId]), ]); return { posts, comments }; }

When you write async and await yourself, the compiler leaves those calls alone. It still converts tagged calls elsewhere in the same function, so a body may mix both styles.

Concurrency needs its own connection. Two sqlAsync calls in a Promise.all run concurrently only if they can each take a connection from the pool. Inside a transaction or a test there is a single connection and Postgres serialises on it, so the same two queries take as long as writing them one after the other.

Where a Declared Type Is the Contract

The compiler awaits a call when it can see the body it resolves to. Where a function's value flows somewhere with a declared type of its own, that type is the contract, and the compiler keeps it as written, so each file's output depends only on the file itself. The build reports each of these cases with the change to make.

A callback declared without a Promise

Wrong
function expectError(desc: string, fn: () => void) { try { fn(); errorf("expected %v to fail", desc); } catch (e) { assert(e instanceof ValidationError); } } expectError("a blank name", () => { signup({ name: " " }); // signup queries, so this callback is async });

The callback is async, so fn() returns a promise instead of throwing. The catch never runs and errorf fires every time. The compiler reports the argument, and you widen the parameter:

Widened parameter
function expectError(desc: string, fn: () => void | Promise<void>) {

The same rule applies wherever a converted function lands: a property in an options object, a class field, an annotated variable, a template attribute.

Other slots
function retry(opts: { run: () => void }) { ... } retry({ run: () => { sql(`...`); } }); // reported: widen `run` class W { onDone: () => void; } w.onDone = () => { sql(`...`); }; // reported: widen `onDone`

Widening states that the slot accepts an async callback. It does not make the compiler await it for you, and widening alone leaves a catch that still cannot run. Mark the function async and await the call:

Right
async function expectError(desc: string, fn: () => void | Promise<void>) { try { await fn(); errorf("expected %v to fail", desc); } catch (e) { assert(e instanceof ValidationError); } }

Calls to a function you marked async are not awaited for you, so update each caller too:

Updated caller
test("a blank name is refused", async () => { await expectError("a blank name", () => signup({ name: " " })); });

Widening without awaiting is reported when the call sits in a try, so you will not ship the half-edit. The same holds for a callback typed () => unknown, () => any or () => Promise<T>: its type allows a promise, so an unawaited call in a try is reported where it sits, and the fix is to await it. To drop the promise on purpose, write void fn(). Everywhere else a discarded promise is left alone: a callback slot that starts work and returns is a real pattern, and Listener.on is one.

Writing async yourself ends the propagation, so the caller is now yours too:

Wrong
let err = onSubmit(handle, password); // a promise, not a value if (err) { // always true ... }

Nothing converts that call, because onSubmit has no @redirect to an async function. Await it, or keep the callback out of it and put the try where the compiler's own await already lands:

Right
function onSubmit(handle: string, password: string) { try { signin(handle, password); return null; } catch (e) { return (e as Error).message; } }

Plain TypeScript has the same hole. Promise<void> is assignable to void, which is what the no-misused-promises lint rule exists for.

A method that implements an interface or overrides a base class

Wrong
interface Repo { find(id: string): User } class PgRepo implements Repo { find(id: string) { return sql<User>(` select * from users where id = $1 `, [id]).firstOrThrow(); } } function greet(repo: Repo, id: string) { return `hello ${repo.find(id).name}`; // resolves to Repo.find, which has no body }

PgRepo.find became async. A call through the Repo type resolves to the interface signature, so there is nothing for the compiler to await, and greet would read .name off a promise. The compiler reports the method. Declare the interface member as returning a promise and await calls made through it:

Right
interface Repo { find(id: string): Promise<User> } async function greet(repo: Repo, id: string) { return `hello ${(await repo.find(id)).name}`; }

The same applies to a base class method overridden by a converted one. If the base method is itself converted, or written async, the two agree and nothing is reported.

A getter, setter, constructor, or parameter default

None of these can be async in JavaScript, so a converted call inside one is reported:

Reported
class Prices { get latest() { return loadPrices(); } // reported constructor() { this.p = loadPrices(); } // reported } function render(prices = loadPrices()) {} // reported

Move the call into a method, or assign the parameter at the top of the body when it is undefined.

A top-level value

Reported
export let prices = loadPrices(); // loadPrices queries console.log(loadPrices()); // same

A module emits as CommonJS, and CommonJS has no top-level await. A top-level call is wrapped in an async function that runs immediately, which works for a statement but not for a value: prices would hold a promise, and reading prices later is not a call, so nothing waits for it. Move the call inside a function:

Right
export function prices() { return loadPrices(); }

A top-level call whose result you do not use is fine:

Allowed
warmCache(); // runs, nothing waits for it

A function you marked async yourself

Three rules cover a function you wrote async:

  1. The function is left as you wrote it, and so are its callers. Propagation stops there. A call to it is not awaited for you, and the function making the call does not become async.
  2. A redirected call inside it, such as sql(), is still awaited for you. So is a call to a function that became async, and a call to an RPC function in the browser.
  3. A direct call to an *Async name with no await in front of it is left alone, in any function. You get the promise.
Marked async
async function save() { // awaited for you (rule 2) sql(` insert into logs ( msg ) values ( 'x' ) `); } function caller() { // not awaited (rule 1) save(); } function start() { // a promise (rule 3) let p = sqlAsync(`select 1`); }

caller stays a plain function, and save() returns a promise there. Writing async says you are managing that promise, so awaiting it is yours to do:

Caller
async function caller() { await save(); }

The same holds for an async arrow function, an async method, and an RPC function you marked async.

Calls You Await Explicitly

Three call shapes pass a function on as a value, so the result is awaited the same way as in any TypeScript program: with await or for await at the call.

  • A function invoked reflectively: await f.call(this), await f.apply(...), await handlers[i]().
  • A callback handed to a library function that uses its return value, such as xs.sort(...) or s.replace(re, ...). Compute the values first, then pass a plain callback.
  • A generator that reads the database becomes async function*, and its consumers iterate it with for await.

See Also

  • man database for sql, tx, and transactions
  • man rpc for calling server code from the browser
  • man tests for test, assert, and equal
  • man channel for listen and notify