Async
elements man async Read as markdownMost functions in an Elements app need no async and no await. You write
ordinary synchronous code. The compiler adds async and await for you, and it
does so up the whole call stack.
This is driven by build tags, not by guesswork. A function opts in with a
@redirect tag in its JSDoc. When you call a function that carries that tag, the
compiler rewrites the call to await the async version, marks your function
async, and then does the same to everything that calls your function.
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:
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 carries a @redirect tag. greet became
async because it calls findUser.
How the Build Tag Works
Here is the redirect tag on sql, in @elements/app:
/**
* @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, emit await sqlAsync
instead. The synchronous sql is the signature you write against. sqlAsync is
what actually runs.
How To Tell Which Functions Participate
The tag is the signal. A function participates if, and only if, its declaration
carries @redirect. The tag ships in the package's type declarations, so you
never have to guess:
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 @redirect, the compiler awaits the call. If it does
not, the function is ordinary TypeScript and the usual rules apply.
These are the redirected functions in @elements/app today. Each has a
counterpart with the same name and an Async suffix, except where noted.
Database, from @elements/app:
sql tx checkoutDb
Methods on a Database or DatabasePool:
connect end checkout begin commit rollback sql tx listen notify
Channels, sessions, email, jobs, files:
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:
view insert update delete more
Tests:
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 */
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 without a @redirect tag 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.
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:
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 You Have to Do It Yourself
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 will not rewrite it: changing a declaration based on what one caller passed would make a file's output depend on the files that import it, and Elements compiles the other way around. Every one of these cases reports an error, so you will not hit them silently.
A callback declared without a Promise
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:
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.
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:
async function expectError(desc: string, fn: () => void | Promise<void>) {
try {
await fn();
errorf("expected %v to fail", desc);
} catch (e) {
assert(e instanceof ValidationError);
}
}
Widening without awaiting is reported when the call sits in a try, so you will
not ship the half-edit. 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:
let err = attemptAuth(handle, password); // a promise, not a value
if (err) { ... } // always true
Nothing converts that call, because attemptAuth carries no @redirect. Await
it, or keep the callback out of it and put the try where the compiler's own
await already lands:
function signin(handle: string, password: string) {
try {
signinUser(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
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:
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:
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
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:
export function prices() {
return loadPrices();
}
A top-level call whose result you do not use is fine:
warmCache(); // runs, nothing waits for it
A function you marked async yourself
async function save() {
await sqlAsync(`insert into logs (msg) values ('x')`);
}
function caller() {
save(); // not awaited
}
The compiler only converts calls that reach a @redirect tag. Once you write
async and call the *Async names yourself, awaiting your function is yours to
do:
async function caller() {
await save();
}
Known Gaps
Three shapes are neither converted nor reported. Each is rare, and each is the same failure: a promise where a value was expected.
- A converted function invoked reflectively:
f.call(this),f.apply(...),handlers[i](). - A converted callback passed to a library function that consumes its return
value:
xs.sort((a, b) => ...),s.replace(re, () => ...). Library callback slots are declared in.d.tsfiles you cannot edit, so they are not reported. - A generator that becomes
async function*: its consumers needfor await, which the compiler does not add.
See Also
man databaseforsql,tx, and transactionsman rpcfor calling server code from the browserman testsfortest,assert, andequalman channelforlistenandnotify