npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gianstack/result

v0.1.1

Published

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

Downloads

197

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

Recommended import contract

  • 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 === "[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 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 === "[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 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("[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 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.