JSPM

@gianstack/result

0.1.1
    • ESM via JSPM
    • ES Module Entrypoint
    • Export Map
    • Keywords
    • License
    • Repository URL
    • TypeScript Types
    • README
    • Created
    • Published
    • Downloads 617
    • Score
      100M100P100Q109179F
    • License MIT

    Transport-agnostic Result and AppError primitives for explicit no-throw expected failures.

    Package Exports

    • @gianstack/result

    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:

    1. a minimal Result union built around ok / err;
    2. a generic AppError shape with stable code: string;
    3. a single normalizeUnknownError(cause, fallback) entrypoint;
    4. small tryResult and tryResultAsync helpers for throw-based APIs.

    What it gives you

    • explicit success and failure branches that TypeScript can narrow;
    • one reusable AppError payload 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, or unwrap.

    Repository-specific error catalogs and transport adapters belong in higher layers.

    Mental model

    • Expected failures travel through Result, not throw.
    • Invariant violations should still fail loudly.
    • AppError.message is developer-oriented only.
    • Call sites should branch on error.code, not on message.
    • cause is preserved for logging and debugging, not for client exposure.
    • normalizeUnknownError requires 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/result
    • import everything from the root entrypoint;
    • define your own local AppError.code union 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 normalizeUnknownError at boundaries or rare catch sites where an unknown failure must become an AppError.

    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 === "taken@example.com") {
        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("alice@example.com");
    
    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 true for any shape compatible with AppError;
    • 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 cause is already an AppError, the same instance is returned;
    • otherwise the fallback code, message, and meta are reused and the original cause is 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 === "taken@example.com") {
        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 TRPCError for protocol and unexpected faults.
    import { TRPCError } from "@trpc/server";
    import { normalizeUnknownError } from "@gianstack/result";
    
    const createUserProcedure = publicProcedure.mutation(async () => {
      try {
        const result = createUser("alice@example.com");
    
        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 data through 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 lint
    • pnpm --filter @gianstack/result check-types
    • pnpm --filter @gianstack/result test
    • pnpm --filter @gianstack/result build
    • pnpm --filter @gianstack/result pack:check
    • pnpm --filter @gianstack/result smoke:consumer

    For repository-level release notes and contributor workflow, see CONTRIBUTING.md and docs/result.md.