@gianstack/result
v0.1.1
Published
Transport-agnostic Result and AppError primitives for explicit no-throw expected failures.
Downloads
197
Maintainers
Readme
@gianstack/result
Introduction
What this package is
@gianstack/result is a small, transport-agnostic package for modeling expected failures without using throw as normal control flow.
It gives you one explicit core:
- a minimal
Resultunion built aroundok/err; - a generic
AppErrorshape with stablecode: string; - a single
normalizeUnknownError(cause, fallback)entrypoint; - small
tryResultandtryResultAsynchelpers for throw-based APIs.
What it gives you
- explicit success and failure branches that TypeScript can narrow;
- one reusable
AppErrorpayload shape for domain and boundary code; - local wrapping of throw-based sync and async APIs;
- a package that stays reusable across server, client, and transport boundaries;
- ESM-only output that is safe to consume in plain TypeScript and Next.js consumers.
What it does not do
This package does not provide:
- a built-in error-code catalog;
- transport mapping for HTTP, GraphQL, tRPC, or Next.js boundaries;
- logging, telemetry, or status-code policies;
- framework adapters;
- monadic helpers like
map,andThen,match, orunwrap.
Repository-specific error catalogs and transport adapters belong in higher layers.
Mental model
- Expected failures travel through
Result, notthrow. - Invariant violations should still fail loudly.
AppError.messageis developer-oriented only.- Call sites should branch on
error.code, not onmessage. causeis preserved for logging and debugging, not for client exposure.normalizeUnknownErrorrequires an explicit fallback so the package never invents repository-specific semantics.- Control flow should stay TypeScript-first and explicit: branch on
result.ok, then use guard clauses.
Support matrix
- ESM only
- root entrypoint only:
@gianstack/result - browser-safe runtime
- server-safe runtime
- no framework peer dependencies
- smoke-tested in this repository with a plain TypeScript consumer and a Next.js 16 consumer
Install
pnpm add @gianstack/resultRecommended import contract
- import everything from the root entrypoint;
- define your own local
AppError.codeunion next to your domain or shared package; - keep transport mapping at the boundary layer, not inside the package;
- use
tryResult*only around APIs that actually throw or reject; - use
normalizeUnknownErrorat boundaries or rare catch sites where anunknownfailure must become anAppError.
User-defined error catalog
This package intentionally leaves AppError.code as string.
You define the catalog that makes sense for your application:
import type { AppError, Result } from "@gianstack/result";
type UserErrorCode =
| "validation_failed"
| "conflict"
| "timeout"
| "unexpected";
type UserError = AppError<UserErrorCode>;
type UserResult<T> = Result<T, UserError>;That keeps the package generic while letting your own code stay fully typed.
Step-by-Step Adoption
Step 1 Define your local error codes
Start by defining the codes your application wants to branch on.
import type { AppError, Result } from "@gianstack/result";
type UserErrorCode =
| "validation_failed"
| "conflict"
| "timeout"
| "unexpected";
type UserError = AppError<UserErrorCode>;
type UserResult<T> = Result<T, UserError>;Step 2 Return Result from domain functions
Expected failures should be explicit at the function boundary.
import { appError, err, ok } from "@gianstack/result";
type UserErrorCode =
| "validation_failed"
| "conflict"
| "unexpected";
type User = { id: string; email: string };
export function createUser(email: string) {
if (!email.includes("@")) {
return err(
appError<UserErrorCode>({
code: "validation_failed",
meta: { field: "email" },
}),
);
}
if (email === "[email protected]") {
return err(appError<UserErrorCode>({ code: "conflict" }));
}
return ok<User>({
id: "user_1",
email,
});
}Step 3 Wrap throw-based synchronous APIs locally
Use tryResult when a dependency can throw and that failure is still expected and handleable.
import { appError, tryResult } from "@gianstack/result";
type ParseInputErrorCode = "unexpected";
export function parseCreateUserInput(raw: string) {
return tryResult(
() => JSON.parse(raw) as { email: string },
(cause) =>
appError<ParseInputErrorCode>({
code: "unexpected",
cause,
}),
);
}Step 4 Wrap async APIs the same way
tryResultAsync does the same job for promise-returning APIs.
import {
appError,
ok,
tryResultAsync,
type AppError,
type Result,
} from "@gianstack/result";
type DirectoryErrorCode = "timeout" | "unexpected";
type DirectoryResult<T> = Result<T, AppError<DirectoryErrorCode>>;
export async function loadDirectoryUser(): Promise<DirectoryResult<{ id: string }>> {
const upstream = await tryResultAsync(
async () => ({ id: "user_1" }),
(cause) =>
appError<DirectoryErrorCode>({
code: "timeout",
cause,
meta: { service: "identity" },
}),
);
if (!upstream.ok) {
return upstream;
}
return ok(upstream.value);
}Step 5 Normalize unknown failures at boundaries
When a boundary still receives an unknown failure, normalize it explicitly with a fallback code owned by your application.
import { normalizeUnknownError, type AppError } from "@gianstack/result";
export function toUserBoundaryError(cause: unknown): AppError<"unexpected"> {
return normalizeUnknownError(cause, {
code: "unexpected",
});
}Step 6 Branch with TypeScript narrowing
Call sites should stay simple and explicit.
const result = createUser("[email protected]");
if (!result.ok) {
switch (result.error.code) {
case "validation_failed":
console.error("Invalid input", result.error.meta);
return;
case "conflict":
console.error("User already exists");
return;
default:
console.error("Unexpected expected failure", result.error.code);
return;
}
}
console.log(result.value.id);API Reference
Ok
Success branch of Result.
type Ok<T> = { readonly ok: true; readonly value: T };Err
Failure branch of Result.
type Err<E> = { readonly ok: false; readonly error: E };Result
The discriminated union used for expected outcomes.
type Result<T, E = unknown> = Ok<T> | Err<E>;ok
Builds the success branch.
import { ok } from "@gianstack/result";
const result = ok({ id: "user_1" });err
Builds the failure branch.
import { appError, err } from "@gianstack/result";
const result = err(appError({ code: "validation_failed" }));tryResult
Wraps a synchronous throw-based API and returns Result.
- If the function succeeds, you get
ok(value). - If it throws and no mapper is provided, the original cause becomes
error. - If a mapper is provided, the mapper decides the failure payload.
import { tryResult } from "@gianstack/result";
const parsed = tryResult(() => JSON.parse("{\"ok\":true}") as { ok: boolean });tryResultAsync
Async counterpart of tryResult.
- If the promise resolves, you get
ok(value). - If it rejects and no mapper is provided, the original rejection reason becomes
error. - If a mapper is provided, the mapper decides the failure payload.
import { tryResultAsync } from "@gianstack/result";
const loaded = await tryResultAsync(async () => ({ id: "user_1" }));AppError
Generic error payload used in Result.err(...).
type AppError<
TCode extends string = string,
TMeta extends Record<string, unknown> = Record<string, unknown>,
> = {
readonly code: TCode;
readonly message?: string;
readonly cause?: unknown;
readonly meta?: TMeta;
};Use it as the stable shape for expected failures, not as a user-facing message contract.
AppErrorInit
Input shape accepted by appError(...).
type AppErrorInit<
TCode extends string = string,
TMeta extends Record<string, unknown> = Record<string, unknown>,
> = {
readonly code: TCode;
readonly message?: string;
readonly cause?: unknown;
readonly meta?: TMeta;
};AppErrorMeta
Convenience alias for structured error metadata.
type AppErrorMeta = Record<string, unknown>;appError
Builds an AppError object from explicit fields.
import { appError } from "@gianstack/result";
const error = appError({
code: "conflict",
meta: { scope: "signup" },
});isAppError
Structural type guard for unknown values.
- returns
truefor any shape compatible withAppError; - does not require a branded instance created by
appError(...); - useful when integrating with boundary code that already receives plain objects.
import { isAppError } from "@gianstack/result";
const value: unknown = { code: "conflict" };
if (isAppError(value)) {
console.log(value.code);
}normalizeUnknownError
Turns an unknown cause into AppError.
- if
causeis already anAppError, the same instance is returned; - otherwise the fallback
code,message, andmetaare reused and the originalcauseis preserved.
import { normalizeUnknownError } from "@gianstack/result";
const error = normalizeUnknownError(new Error("boom"), {
code: "unexpected",
meta: { scope: "create-user" },
});Examples
Example service function
import { appError, err, ok, type AppError, type Result } from "@gianstack/result";
type SaveUserResult = Result<
{ id: string; email: string },
AppError<"validation_failed" | "conflict">
>;
export function saveUser(email: string): SaveUserResult {
if (!email.includes("@")) {
return err(appError({ code: "validation_failed" }));
}
if (email === "[email protected]") {
return err(appError({ code: "conflict" }));
}
return ok({
id: "user_1",
email,
});
}Example sync wrapper
import { appError, tryResult } from "@gianstack/result";
export function parsePayload(raw: string) {
return tryResult(
() => JSON.parse(raw) as { id: string },
(cause) => appError({ code: "unexpected", cause }),
);
}Example async wrapper
import { appError, tryResultAsync } from "@gianstack/result";
export async function loadProfile() {
return tryResultAsync(
async () => ({ id: "user_1", name: "Alice" }),
(cause) =>
appError({
code: "timeout",
cause,
meta: { service: "profiles" },
}),
);
}Example boundary normalization
import { normalizeUnknownError } from "@gianstack/result";
export function toLoggedError(cause: unknown) {
const error = normalizeUnknownError(cause, {
code: "unexpected",
meta: { boundary: "users.route" },
});
return {
code: error.code,
cause: error.cause,
};
}Cookbook
These are integration patterns, not package exports.
@gianstack/result does not ship Next.js, tRPC, GraphQL, Yoga, or Pothos helpers. The examples below show how to keep your transport boundary thin while still using Result<AppError> in domain code.
Next.js App Router: Route Handler
Use the domain layer for expected failures, then map them to Response or NextResponse at the boundary.
import { NextResponse } from "next/server";
import { normalizeUnknownError } from "@gianstack/result";
import { createUser } from "@/server/users";
export async function POST(request: Request) {
try {
const body = (await request.json()) as { email?: string };
const result = createUser(body.email ?? "");
if (!result.ok) {
switch (result.error.code) {
case "validation_failed":
return NextResponse.json(
{ type: "error", error: { code: result.error.code } },
{ status: 400 },
);
case "conflict":
return NextResponse.json(
{ type: "error", error: { code: result.error.code } },
{ status: 409 },
);
default:
return NextResponse.json(
{ type: "error", error: { code: result.error.code } },
{ status: 500 },
);
}
}
return NextResponse.json(
{ type: "success", data: result.value },
{ status: 201 },
);
} catch (cause) {
const error = normalizeUnknownError(cause, {
code: "unexpected",
});
return NextResponse.json(
{ type: "error", error: { code: error.code } },
{ status: 500 },
);
}
}Next.js App Router: Server Action
Server Actions usually need a serializable payload, so keep Result<AppError> in the service layer and map it at the action boundary.
"use server";
import { createUser } from "@/server/users";
type CreateUserActionState =
| { type: "success"; data: { id: string; email: string } }
| {
type: "error";
error: { code: "validation_failed" | "conflict" | "unexpected" };
};
export async function createUserAction(
_previousState: CreateUserActionState | null,
formData: FormData,
): Promise<CreateUserActionState> {
const result = createUser(String(formData.get("email") ?? ""));
if (!result.ok) {
return {
type: "error",
error: { code: result.error.code },
};
}
return {
type: "success",
data: result.value,
};
}tRPC: typed payloads for expected failures
One compatible tRPC pattern is:
- return typed payloads for expected failures;
- reserve
TRPCErrorfor protocol and unexpected faults.
import { TRPCError } from "@trpc/server";
import { normalizeUnknownError } from "@gianstack/result";
const createUserProcedure = publicProcedure.mutation(async () => {
try {
const result = createUser("[email protected]");
if (!result.ok) {
return {
type: "error" as const,
error: {
code: result.error.code,
},
};
}
return {
type: "success" as const,
data: result.value,
};
} catch (cause) {
const error = normalizeUnknownError(cause, {
code: "unexpected",
});
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
cause: error.cause,
});
}
});GraphQL Yoga + Pothos: errors in data
One compatible GraphQL pattern is:
- return success and expected failures in
datathrough a result union; - let protocol or unexpected faults go through GraphQL
errors[].
import { normalizeUnknownError } from "@gianstack/result";
type CreateUserGraphqlResult =
| {
__typename: "CreateUserSuccess";
user: { id: string; email: string };
}
| {
__typename: "ValidationFailedError";
code: "validation_failed";
}
| {
__typename: "ConflictError";
code: "conflict";
};
export async function resolveCreateUser(input: { email: string }) {
try {
const result = createUser(input.email);
if (result.ok) {
return {
__typename: "CreateUserSuccess" as const,
user: result.value,
};
}
switch (result.error.code) {
case "validation_failed":
return {
__typename: "ValidationFailedError" as const,
code: result.error.code,
};
case "conflict":
return {
__typename: "ConflictError" as const,
code: result.error.code,
};
default:
throw new Error(`Unhandled expected code: ${result.error.code}`);
}
} catch (cause) {
const error = normalizeUnknownError(cause, {
code: "unexpected",
});
throw error.cause instanceof Error ? error.cause : new Error(error.code);
}
}In Yoga + Pothos terms, the important part is the contract:
- success and expected failures become typed union members in
data; - clients branch on
__typename; - unexpected faults stay in the GraphQL error channel.
Development contract
Before publishing a new version, this package must pass:
pnpm --filter @gianstack/result lintpnpm --filter @gianstack/result check-typespnpm --filter @gianstack/result testpnpm --filter @gianstack/result buildpnpm --filter @gianstack/result pack:checkpnpm --filter @gianstack/result smoke:consumer
For repository-level release notes and contributor workflow, see CONTRIBUTING.md and docs/result.md.
