✨ TLDR
- → A composable that exposes `{ data, error, isLoading }` with `error: Ref<Error | null>` hides the most important thing about itself: how it can fail.
- → Swap the loose refs for a single `Ref<Result<T, E>>` where `E` is a discriminated union of the failures you actually care about.
- → Vue templates handle discriminated unions natively — `v-if` chains on a `kind` field narrow the type for free.
- → You don't need a library. A working `Result` type is twelve lines. Reach for `neverthrow`, `better-result`, or `Effect` only when the boilerplate starts to bite.
The Composable That Lies
Three years ago I wrote a post on error handling in Vue composables Best Practices for Error Handling in Vue Composables Error handling can be complex, but it's crucial for composables to manage errors consistently. This post explores an effective method for implementing error handling in composables. . The pattern was: catch inside the composable, expose a reactive error object, let the component react to it. It works. It’s also lying to you, and I want to talk about that.
Here’s a useBooking composable in the shape most of us still write:
// composables/useBooking.ts
import { ref } from "vue";
export function useBooking() {
const booking = ref<Booking | null>(null);
const error = ref<Error | null>(null);
const isLoading = ref(false);
async function reserve(req: BookingRequest) {
isLoading.value = true;
error.value = null;
try {
booking.value = await gds.reserveRoom(req);
} catch (e) {
error.value = e as Error;
} finally {
isLoading.value = false;
}
}
return { booking, error, isLoading, reserve };
}
The return type tells you error is an Error or null. That is technically true and operationally useless. The hotel booking call can fail because:
- the room sold out between page-load and click
- the rate changed
- the credit card processor timed out (but the charge may have actually gone through)
- the GDS rate-limited us
- the input was malformed
Every one of those failures wants a different UI response. One wants alternates. One wants a re-confirm dialog with the new price. One wants a “do not retry — we’re investigating” message and a page to ops. The composable knows none of this, and neither does the component using it.
Six Months From Now
This is what consuming the composable looks like in the template:
<script setup lang="ts">
import { useBooking } from "@/composables/useBooking";
const { booking, error, isLoading, reserve } = useBooking();
</script>
<template>
<div v-if="isLoading">Booking…</div>
<div v-else-if="booking">Confirmed: {{ booking.confirmationCode }}</div>
<div v-else-if="error">Something went wrong. Please try again.</div>
</template>
You’ve seen this template a thousand times. It’s also where credit-card-timeout errors get silently retried and you double-charge a customer.
You can make it worse by trying to make it better:
<div v-else-if="error">
<p v-if="error.message.toLowerCase().includes('rate')">
The rate changed. Please re-confirm.
</p>
<p v-else-if="(error as any).code === 'ROOM_UNAVAILABLE'">
That room sold out — here are alternates.
</p>
<p v-else>Something went wrong. Please try again.</p>
</div>
Now your template is doing string-matching and as any casts to recover information the composable threw away. The compiler can’t help you. Add a new failure mode upstream and nothing tells you the template needs updating.
A Result Type, In About Twelve Lines
You don’t need a library for this. The pattern is a discriminated union on a kind field — Vue templates already love those.
// utils/result.ts
export type Result<T, E> =
| { kind: "ok"; value: T }
| { kind: "err"; error: E };
export const ok = <T>(value: T): Result<T, never> => ({ kind: "ok", value });
export const err = <E>(error: E): Result<never, E> => ({ kind: "err", error });
That’s it. No classes, no generators, no monad gymnastics. The whole pattern hinges on one move: make the failure type part of the signature.
Now define what can go wrong as a discriminated union of plain tagged objects. Same kind discriminant, different payloads:
// composables/useBooking.errors.ts
export type BookingError =
| { kind: "RoomUnavailable"; hotelId: string; roomCode: string }
| { kind: "RateChanged"; oldRate: number; newRate: number }
| { kind: "PaymentLimbo"; attemptId: string; cause: unknown }
| { kind: "TransientVendorError"; vendor: string }
| { kind: "InvalidBookingInput"; field: string; message: string };
These aren’t decorative. Each variant carries the fields the UI actually needs to render its branch — oldRate/newRate, hotelId, field. The error becomes a payload, not just a string.
Wrap The Vendor SDK At The Edge
The string-sniffing logic doesn’t disappear, it moves to the place it belongs: the boundary where you call the vendor. This is the anti-corruption layer pattern from DDD.
// services/booking.ts
import { ok, err, type Result } from "@/utils/result";
import type { BookingError } from "@/composables/useBooking.errors";
export async function reserveRoom(
req: ValidatedBooking,
): Promise<Result<Booking, BookingError>> {
try {
return ok(await gds.reserveRoom(req));
} catch (cause) {
if (isOtaCode(cause, 299)) {
return err({
kind: "PaymentLimbo",
attemptId: req.attemptId,
cause,
});
}
if (isOtaCode(cause, 410)) {
return err({
kind: "RoomUnavailable",
hotelId: req.hotelId,
roomCode: req.roomCode,
});
}
return err({
kind: "TransientVendorError",
vendor: "sabre",
});
}
}
The SDK-shape narrowing happens once, where the vendor’s quirks live. Everything upstream gets a typed BookingError instead of unknown.
The Composable, Rewritten
// composables/useBooking.ts
import { ref, type Ref } from "vue";
import { reserveRoom } from "@/services/booking";
import { validateBookingRequest } from "@/services/validation";
import type { Result } from "@/utils/result";
import type { BookingError } from "./useBooking.errors";
export function useBooking() {
const result: Ref<Result<Booking, BookingError> | null> = ref(null);
const isLoading = ref(false);
async function reserve(req: BookingRequest) {
isLoading.value = true;
const validated = validateBookingRequest(req);
if (validated.kind === "err") {
result.value = validated;
isLoading.value = false;
return;
}
result.value = await reserveRoom(validated.value);
isLoading.value = false;
}
return { result, isLoading, reserve };
}
One ref instead of three. The shape of the success and the shape of every failure are both in the type signature. The early-return pattern on validated is the same short-circuit Rust’s ? operator does — just spelled out.
The Template Reads The Errors
Here’s where the payoff lands. Vue templates handle discriminated unions natively. Use v-if chains on kind, and the compiler narrows the type inside each branch.
<script setup lang="ts">
import { useBooking } from "@/composables/useBooking";
const { result, isLoading, reserve } = useBooking();
</script>
<template>
<BookingLoading v-if="isLoading" />
<template v-else-if="result?.kind === 'ok'">
<BookingConfirmed :booking="result.value" />
</template>
<template v-else-if="result?.kind === 'err'">
<RoomAlternates
v-if="result.error.kind === 'RoomUnavailable'"
:hotel-id="result.error.hotelId"
/>
<ReconfirmPrice
v-else-if="result.error.kind === 'RateChanged'"
:old-rate="result.error.oldRate"
:new-rate="result.error.newRate"
/>
<PaymentEscalation
v-else-if="result.error.kind === 'PaymentLimbo'"
:attempt-id="result.error.attemptId"
/>
<RetryPrompt
v-else-if="result.error.kind === 'TransientVendorError'"
@retry="reserve(req)"
/>
<FieldError
v-else-if="result.error.kind === 'InvalidBookingInput'"
:field="result.error.field"
:message="result.error.message"
/>
</template>
</template>
If you want exhaustiveness enforced — drop a variant and have tsc fail — push the matching into a computed and use a never-check:
import { computed } from "vue";
const view = computed(() => {
if (isLoading.value) return { kind: "loading" } as const;
if (!result.value) return { kind: "idle" } as const;
if (result.value.kind === "ok") {
return { kind: "success", booking: result.value.value } as const;
}
const e = result.value.error;
switch (e.kind) {
case "RoomUnavailable":
return { kind: "alternates", hotelId: e.hotelId } as const;
case "RateChanged":
return { kind: "reconfirm", oldRate: e.oldRate, newRate: e.newRate } as const;
case "PaymentLimbo":
return { kind: "escalate", attemptId: e.attemptId } as const;
case "TransientVendorError":
return { kind: "retry" } as const;
case "InvalidBookingInput":
return { kind: "form-error", field: e.field } as const;
default: {
const _exhaustive: never = e;
return _exhaustive;
}
}
});
Add a new variant to BookingError and tsc fails on _exhaustive: never until every template handles it. The error path is part of the contract, enforced.
Caveat: Suspense And onErrorCaptured
This pattern opts out of Vue’s thrown-error machinery. <Suspense>’s onError slot, onErrorCaptured, and global error handlers all expect something to throw. A composable returning a Result doesn’t.
Pick deliberately:
- Result-style when the failure modes drive UI decisions (booking, checkout, multi-step forms).
- Throw-style when failure means “render the error boundary” (route-level data fetching, top-level
<Suspense>shells).
You can bridge: unwrap the Result at the component edge and throw if the variant is one your error boundary should catch. The boundary still gets a typed error, the rest of the template still gets exhaustive matching.
Where To Adopt First
Don’t boil the ocean. Pick the composable in your codebase whose catch block hurts the most — the one with comments like // TODO: distinguish 4xx from 5xx, or the one wrapped in three layers of if (e.message.includes(...)). Wrap that one call, define its variants, match it in one template, ship that PR. The next composable is easier.
If You Don’t Want To Roll Your Own
The twelve-line Result and the early-return chaining work. Once you have more than a handful of these in a codebase, you’ll start wanting helpers — map, andThen, tap, retry policies, partial matching. A few libraries package this up:
neverthrow— the long-running, minimal option.Result<T, E>with chainable methods. Closest to what we built here.better-result— newer, ergonomic API withTaggedError, generator-based composition, and built-in retry config.- Effect — the full ecosystem: Result types are one feature alongside DI, fibers, streams, schema validation. Heavier learning curve, much bigger payoff if you commit.
All three solve the same core problem we just solved by hand. Reach for them when the boilerplate starts to bite, not before.
The composables you write next don’t have to lie. Make the failure path part of the signature.