npm package: @gianstack/result
Description: Transport-agnostic Result and AppError primitives for explicit no-throw expected failures.
Latest version: 0.1.1

# `@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

```bash
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:

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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`.

```ts
type Ok<T> = { readonly ok: true; readonly value: T };
```

### `Err`

Failure branch of `Result`.

```ts
type Err<E> = { readonly ok: false; readonly error: E };
```

### `Result`

The discriminated union used for expected outcomes.

```ts
type Result<T, E = unknown> = Ok<T> | Err<E>;
```

### `ok`

Builds the success branch.

```ts
import { ok } from "@gianstack/result";

const result = ok({ id: "user_1" });
```

### `err`

Builds the failure branch.

```ts
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.

```ts
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.

```ts
import { tryResultAsync } from "@gianstack/result";

const loaded = await tryResultAsync(async () => ({ id: "user_1" }));
```

### `AppError`

Generic error payload used in `Result.err(...)`.

```ts
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(...)`.

```ts
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.

```ts
type AppErrorMeta = Record<string, unknown>;
```

### `appError`

Builds an `AppError` object from explicit fields.

```ts
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.

```ts
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.

```ts
import { normalizeUnknownError } from "@gianstack/result";

const error = normalizeUnknownError(new Error("boom"), {
  code: "unexpected",
  meta: { scope: "create-user" },
});
```

## Examples

### Example service function

```ts
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

```ts
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

```ts
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

```ts
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.

```ts
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.

```ts
"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.

```ts
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[]`.

```ts
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`](../../CONTRIBUTING.md) and [`docs/result.md`](../../docs/result.md).