Async
elements man async Read as markdownElements 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.tsimport { 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 outputasync 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:
Terminalgrep -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:
Databasesql tx checkoutDb
Methods on a Database or DatabasePool:
Database methodsconnect end checkout begin commit rollback sql tx listen notify
Channels, sessions, email, jobs, files:
Channels, sessions, jobschannel.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:
LiveTableview insert update delete more
Tests:
Testtest("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 namelet 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 queriesexport 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
Wrongfunction 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 parameterfunction 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 slotsfunction 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:
Rightasync 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 callertest("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:
Wronglet 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:
Rightfunction 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
Wronginterface 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:
Rightinterface 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:
Reportedclass 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
Reportedexport 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:
Rightexport function prices() { return loadPrices(); }
A top-level call whose result you do not use is fine:
AllowedwarmCache(); // runs, nothing waits for it
A function you marked async yourself
Three rules cover a function you wrote async:
- 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. - A redirected call inside it, such as
sql(), is still awaited for you. So is a call to a function that becameasync, and a call to an RPC function in the browser. - A direct call to an
*Asyncname with noawaitin front of it is left alone, in any function. You get the promise.
Marked asyncasync 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:
Callerasync 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(...)ors.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 withfor await.
See Also
man databaseforsql,tx, and transactionsman rpcfor calling server code from the browserman testsfortest,assert, andequalman channelforlistenandnotify