Skip to content

Errors Across the Wire

A server action can return a Result to the browser. Workers and cache layers can send the same Result through JSON. Serialization preserves supported data and discards class identity.

The Result wrapper contains plain data. Its value and error still need to use types that your transport supports.

const result = await findUser('u-1');
// { ok: false, error: { type: 'NOT_FOUND', id: 'u-1' } }
JSON.parse(JSON.stringify(result));
// { ok: false, error: { type: 'NOT_FOUND', id: 'u-1' } }

isOk and isErr read the ok property, so they work on Results parsed from JSON:

import { isErr } from 'awaitly';
const revived = await response.json();
if (isErr(revived)) console.error(revived.error.type);

Return a Result from a server action and read its ok field in the client:

'use server';
export async function loadUser(id: string) {
return findUser(id); // AsyncResult<User, NotFound>
}
'use client';
import { isErr } from 'awaitly';
const result = await loadUser('u-1');
if (isErr(result)) return <Error code={result.error.type} />;
return <Profile user={result.value} />;

Cached functions, Worker RPC, and JSON queues can use the same shape. Keep the payload JSON-safe and validate data from outside your trust boundary.

Error stores message and stack as non-enumerable properties. JSON.stringify drops both properties unless the class supplies a JSON representation.

Each TaggedError supplies toJSON:

import { TaggedError } from 'awaitly';
class NotFoundError extends TaggedError('NotFoundError', {
message: (p: { id: string }) => `No user with id ${p.id}`,
})<{ id: string }> {}
JSON.stringify(new NotFoundError({ id: 'u-1' }));
// {"type":"NotFoundError","id":"u-1","message":"No user with id u-1"}

The payload contains the discriminant, message, and enumerable props. toJSON omits the stack because it can expose file paths from the sender.

JSON parsing returns a plain object. Read error.type across the boundary:

const { error } = await response.json();
error.type === 'NotFoundError'; // true
error instanceof NotFoundError; // false

Validate the payload at the boundary, then handle the resulting discriminated union by type:

type WireError =
| { type: 'NotFoundError'; id: string; message: string }
| { type: 'ForbiddenError'; message: string }
| { type: 'UnexpectedError'; message: string };
function status(error: WireError): number {
switch (error.type) {
case 'NotFoundError': return 404;
case 'ForbiddenError': return 403;
case 'UnexpectedError': return 500;
}
}

Each iframe, node:vm context, and Worker owns a separate Error constructor. An error created in one realm can fail an instanceof check in another. The string in type crosses both realm and serialization boundaries.

fromPromise and tryAsync accept PromiseLike values:

import { fromPromise } from 'awaitly';
// A promise a framework created and passed across a boundary
export async function loadData(dataPromise: PromiseLike<Data>) {
const result = await fromPromise(dataPromise, () => 'LOAD_FAILED' as const);
return result;
}

Frameworks and libraries can expose thenables without the full native Promise API. Both helpers await the then method, so the narrower PromiseLike contract is enough.