Manual Async

Async

elements man async Read as markdown

Most 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.ts files you cannot edit, so they are not reported.
  • A generator that becomes async function*: its consumers need for await, which the compiler does not add.

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