Braden Wong

Results All the Way Up

Let errors travel through the application until the event handler turns them into something the user can see.

Pass Results all the way up, then handle the outcome at the event handler and return void.

<button
  onclick={async (): Promise<void> => {
    const result: Result<void, AppError> = await completeOperation();
    if (result.error) toast.error(result.error.message);
  }}
>
  Do the thing
</button>

Each caller can handle that result or pass it back up. But the event handler doesn’t return another Result. It decides what to show the user.

The result keeps moving upward

event handler       -> toast, dialog, or done
      ^
      | Result
complete operation
      ^
      | Result
smaller operation

Suppose one operation produces data that another operation needs:

const first: Result<Data, AppError> = await operationOne();
if (first.error) return Err(first.error);

const second: Result<void, AppError> = await operationTwo(first.data);

The lower functions do not decide how an error should appear to a person. They return the error so the next layer can decide whether it can recover, add more context, or pass it upward again.

The event handler is the boundary

The complete operation composes the smaller operations. The button handles its final result:

<script lang="ts">
  import { Err, Ok, type Result } from 'wellcrafted/result';
  import { toast } from 'svelte-sonner';

  // Returns the outcome to its caller.
  async function completeOperation(): Promise<Result<void, AppError>> {
    const { data, error } = await operationOne();
    if (error) return Err(error);

    const { error: secondError } = await operationTwo(data);
    if (secondError) return Err(secondError);

    return Ok(undefined);
  }
</script>

<button
  type="button"
  onclick={async (): Promise<void> => {
    const { error } = await completeOperation();

    // Handles the outcome. No Result goes back to the caller.
    if (error) toast.error(error.message);
  }}
>
  Do the thing
</button>

completeOperation returns a result whose success value is void. It still tells its caller whether the operation succeeded. The event handler returns Promise<void>: it has handled the outcome, so there is no result left to pass back.

Here, we’ve decided that an error should appear as a toast. The operation supplies the error; the event handler chooses how to show it.

Extracting the pattern

If several buttons handle errors this way, we can pull that behavior into a toastOnError helper:

<script lang="ts">
  import { toast } from 'svelte-sonner';

  function toastOnError(result: Result<unknown, AppError>): void {
    if (result.error !== null) toast.error(result.error.message);
  }
</script>

<button
  type="button"
  onclick={async () => toastOnError(await completeOperation())}
>
  Do the thing
</button>

The architecture is now compressed into a single expression:

completeOperation() → Result
                    → toastOnError(...)
                    → user-facing effect

Results all the way up. At the event handler, the result becomes a response to the user.