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.
The tag ships in the package's type declarations, so you can read it yourself:
grep -A2 "@redirect" node_modules/@elements/app/server/index.d.ts
That means you never have to guess whether a function makes yours async. Look at
its declaration. If it has @redirect, it does.
Which Functions Participate
Database:
sql tx begin commit rollback
connect end listen notify checkout
Sessions, email, jobs, files:
session.login session.logout session.renew session.revoke
email send schedule file.read
LiveTable writes, from the browser:
insert update delete
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: see man tests.
RPC functions are converted too. A function tagged @rpc 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.
Which Name To Write
Two rules decide it, and neither needs judgement.
Write the plain name. The compiler inserts the await. Never write await
on a plain-name call 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 plain name.
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); // plain: 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, whose promise is owned by the test runner and is never yours
to compose. 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 plain-name 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
There are three cases the compiler does not handle. Each one reports an error, so you will not hit them silently.
A callback parameter 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 will not fix this by rewriting expectError, because changing a
function based on what one of its callers passed would make a file's output
depend on the files that import it. Elements compiles the other way around. So it
reports the error and you widen the parameter:
function expectError(desc: string, fn: () => void | Promise<void>) {
Adding Promise to the return type allows an async callback. It does not make
the compiler await it for you. Handle the returned value where you call it:
let result = fn();
if (result != null && typeof result.then === "function") {
return result.then(
() => errorf("expected %v to fail", desc),
(e) => assert(e instanceof ValidationError),
);
}
Note that plain TypeScript has the same hole. Promise<void> is assignable to
void, which is what the no-misused-promises lint rule exists for.
A top-level variable
export let prices = loadPrices(); // loadPrices queries
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 to declarations that carry @redirect. Once you
write async yourself, awaiting it is yours to do:
async function caller() {
await save();
}
See Also
man databaseforsql,tx, and transactionsman rpcfor calling server code from the browserman testsfortest,assert, andequalman channelforlistenandnotify