@pbpeterson/typed-fetch
v0.8.1
Published
A type-safe HTTP client that never throws. Inspired by Go's error handling pattern.
Maintainers
Readme
@pbpeterson/typed-fetch
A type-safe HTTP client for TypeScript that never throws. Inspired by Go's error handling pattern, built on top of the native Fetch API.
Why typed-fetch?
Traditional fetch libraries throw exceptions on HTTP errors, making error handling cumbersome and error-prone. typed-fetch follows Go's philosophy of explicit error handling - errors are values, not exceptions.
// ❌ Traditional approach - can throw unexpectedly
try {
const response = await fetch("/api/users");
const users = await response.json(); // What if response is 404?
} catch (error) {
// Handle network errors, parsing errors, HTTP errors... all mixed together
}
// ✅ typed-fetch approach - explicit and type-safe
const { response, error } = await typedFetch<User[]>("/api/users");
if (error) {
// Handle error with full type information
console.log(`HTTP ${error.status}: ${error.statusText}`);
const errorDetails = await error.json(); // Access error response body
} else {
// TypeScript knows response is not null
const users = await response.json(); // Type: User[]
}Features
- Never throws - All errors are returned as values
- Fully typed - Complete TypeScript support with literal status types
- Built on Fetch - Thin wrapper around the native Fetch API, same signature
- 40 HTTP error classes - Covering all standard HTTP status codes (400-511)
- No status code left behind - Non-standard error codes (e.g. 420, 599) become
UnknownHttpError - Network error handling - Separate
NetworkErrorclass for connection issues, with the original error preserved oncause - Type guards -
isHttpError()andisNetworkError()for runtime checks - Generic error bodies -
error.json<T>()for typed error response parsing - Zero dependencies
Installation
npm install @pbpeterson/typed-fetchRequires Node.js >= 20 (or any runtime with the native Fetch API: browsers, Deno, Bun, edge runtimes).
Agent Skill
Using Claude Code or another agent? Install the typed-fetch skill so your agent knows the API and error-handling patterns:
npx skills add pbpeterson/typed-fetch --skill typed-fetchBasic Usage
Simple GET Request
import { typedFetch } from "@pbpeterson/typed-fetch";
interface User {
id: number;
name: string;
email: string;
}
const { response, error } = await typedFetch<User[]>("/api/users");
if (error) {
console.error("Failed to fetch users:", error.statusText);
} else {
const users = await response.json(); // Type: User[]
}POST Request with Body
import { typedFetch, BadRequestError } from "@pbpeterson/typed-fetch";
const { response, error } = await typedFetch<User>("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "John", email: "[email protected]" }),
});
if (error) {
if (error instanceof BadRequestError) {
const details = await error.json<{ field: string; message: string }>();
console.error("Validation failed:", details);
}
}Error Handling
HTTP Status Errors
import {
typedFetch,
NotFoundError,
UnauthorizedError,
NetworkError,
} from "@pbpeterson/typed-fetch";
const { response, error } = await typedFetch<User>("/api/users/123");
if (error) {
if (error instanceof NotFoundError) {
console.log("User not found");
} else if (error instanceof UnauthorizedError) {
console.log("Please log in");
} else if (error instanceof NetworkError) {
console.log("Network error:", error.message);
}
}Unknown Status Codes
Status codes >= 400 without a dedicated class (non-standard or vendor-specific, e.g. 420 or 599) are returned as UnknownHttpError, so no error response ever slips through as a success:
import { typedFetch, UnknownHttpError } from "@pbpeterson/typed-fetch";
const { response, error } = await typedFetch("/api/legacy");
if (error instanceof UnknownHttpError) {
console.log(error.status); // whatever the server sent, e.g. 599
const body = await error.text();
}Network Errors and cause
NetworkError preserves the original error thrown by fetch on cause, so you can distinguish connection failures from aborted requests:
const { response, error } = await typedFetch("https://unreachable.example");
if (isNetworkError(error)) {
console.log(error.message); // "fetch failed"
console.log(error.cause); // the original TypeError, AbortError, etc.
if ((error.cause as Error)?.name === "AbortError") {
// request was cancelled, not a real network failure
}
}Timeouts
No custom timeout API needed — use the standard AbortSignal.timeout(), exactly like with native fetch:
const { response, error } = await typedFetch<User[]>("/api/users", {
signal: AbortSignal.timeout(5000), // NetworkError after 5s
});Type Guards
Use isHttpError() and isNetworkError() instead of instanceof for reliable checks across package boundaries:
import { typedFetch, isHttpError, isNetworkError } from "@pbpeterson/typed-fetch";
const { response, error } = await typedFetch<User>("/api/users/123");
if (error) {
if (isHttpError(error)) {
console.log(`HTTP ${error.status}: ${error.statusText}`);
} else if (isNetworkError(error)) {
console.log("Connection failed:", error.message);
}
}Typed Error Response Bodies
The json() method accepts a generic type parameter:
interface ApiError {
message: string;
code: string;
fields?: Record<string, string>;
}
if (error instanceof BadRequestError) {
const details = await error.json<ApiError>();
console.log(details.message); // fully typed
}Narrowing with Specific Client Errors
Constrain expected client errors (4xx) as a second generic parameter. Server errors (5xx) are always included since they can happen regardless:
import { typedFetch, BadRequestError, NotFoundError } from "@pbpeterson/typed-fetch";
type ExpectedErrors = BadRequestError | NotFoundError;
const { response, error } = await typedFetch<User, ExpectedErrors>("/api/users/123");
// error: BadRequestError | NotFoundError | ServerErrors | UnknownHttpError | NetworkError | nullError Response Bodies
All HTTP error classes provide access to the response body:
if (error && isHttpError(error)) {
const json = await error.json();
const text = await error.clone().text();
const blob = await error.clone().blob();
const buffer = await error.clone().arrayBuffer();
// Access response headers
const retryAfter = error.headers.get("Retry-After");
// Status info with literal types
error.status; // 404 (literal, not number)
error.statusText; // "Not Found" (literal, not string)
}Static Properties
Access status codes without creating instances:
import { NotFoundError, BadRequestError } from "@pbpeterson/typed-fetch";
console.log(NotFoundError.status); // 404
console.log(NotFoundError.statusText); // "Not Found"
console.log(BadRequestError.status); // 400
console.log(BadRequestError.statusText); // "Bad Request"Available Error Classes
4xx Client Errors
| Class | Status | Status Text |
| ----------------------------------- | ------ | ------------------------------- |
| BadRequestError | 400 | Bad Request |
| UnauthorizedError | 401 | Unauthorized |
| PaymentRequiredError | 402 | Payment Required |
| ForbiddenError | 403 | Forbidden |
| NotFoundError | 404 | Not Found |
| MethodNotAllowedError | 405 | Method Not Allowed |
| NotAcceptableError | 406 | Not Acceptable |
| ProxyAuthenticationRequiredError | 407 | Proxy Authentication Required |
| RequestTimeoutError | 408 | Request Timeout |
| ConflictError | 409 | Conflict |
| GoneError | 410 | Gone |
| LengthRequiredError | 411 | Length Required |
| PreconditionFailedError | 412 | Precondition Failed |
| RequestTooLongError | 413 | Payload Too Large |
| RequestUriTooLongError | 414 | URI Too Long |
| UnsupportedMediaTypeError | 415 | Unsupported Media Type |
| RequestedRangeNotSatisfiableError | 416 | Range Not Satisfiable |
| ExpectationFailedError | 417 | Expectation Failed |
| ImATeapotError | 418 | I'm a teapot |
| MisdirectedRequestError | 421 | Misdirected Request |
| UnprocessableEntityError | 422 | Unprocessable Entity |
| LockedError | 423 | Locked |
| FailedDependencyError | 424 | Failed Dependency |
| TooEarlyError | 425 | Too Early |
| UpgradeRequiredError | 426 | Upgrade Required |
| PreconditionRequiredError | 428 | Precondition Required |
| TooManyRequestsError | 429 | Too Many Requests |
| RequestHeaderFieldsTooLargeError | 431 | Request Header Fields Too Large |
| UnavailableForLegalReasonsError | 451 | Unavailable For Legal Reasons |
5xx Server Errors
| Class | Status | Status Text |
| ------------------------------------ | ------ | ------------------------------- |
| InternalServerError | 500 | Internal Server Error |
| NotImplementedError | 501 | Not Implemented |
| BadGatewayError | 502 | Bad Gateway |
| ServiceUnavailableError | 503 | Service Unavailable |
| GatewayTimeoutError | 504 | Gateway Timeout |
| HttpVersionNotSupportedError | 505 | HTTP Version Not Supported |
| VariantAlsoNegotiatesError | 506 | Variant Also Negotiates |
| InsufficientStorageError | 507 | Insufficient Storage |
| LoopDetectedError | 508 | Loop Detected |
| NotExtendedError | 510 | Not Extended |
| NetworkAuthenticationRequiredError | 511 | Network Authentication Required |
Other
| Class | Description |
| ------------------ | ---------------------------------------------------------------- |
| UnknownHttpError | Any status code >= 400 without a dedicated class (e.g. 420, 599) |
| NetworkError | Connection issues, DNS failures, timeouts, aborted requests |
| BaseHttpError | Abstract base class for all HTTP errors |
API Reference
typedFetch<T, E>(url, options?)
Type Parameters:
T- The expected response body typeE extends ClientErrors- Specific client error type(s) (defaults to all)
Parameters:
url- The URL to fetch (same asfetch())options- Fetch options with typedheadersandmethod(optional)
Returns:
Promise<
| { response: TypedResponse<T>; error: null }
| { response: null; error: E | ServerErrors | UnknownHttpError | NetworkError }
>;isHttpError(error): error is BaseHttpError
Type guard that checks if an error is an HTTP error (any status code).
isNetworkError(error): error is NetworkError
Type guard that checks if an error is a network-level error.
statusCodeErrorMap
A ReadonlyMap<number, ErrorClass> mapping HTTP status codes to their error classes. Useful for custom error handling logic.
httpErrors
Array of all 40 HTTP error classes. Useful for iteration and custom registries.
Exported Types
All public types are exported for building typed wrappers around typedFetch:
import type {
TypedResponse, // Response with typed json() and clone()
TypedFetchReturnType, // the discriminated union typedFetch resolves to
TypedFetchOptions, // RequestInit with typed headers and method
TypedHeaders, // headers with IntelliSense for common names
StrictHeaders, // the strict header name/value map
HttpMethods, // "GET" | "POST" | ... (fetch-forbidden methods excluded)
ClientErrors, // union of all 4xx error instances
ServerErrors, // union of all 5xx error instances
TypedFetchError, // every error typedFetch can return
} from "@pbpeterson/typed-fetch";
// Example: a typed wrapper with shared options
async function api<T>(path: string, options?: TypedFetchOptions): Promise<TypedFetchReturnType<T>> {
return typedFetch<T>(`https://api.example.com${path}`, options);
}Error classes are also available from the @pbpeterson/typed-fetch/errors subpath if you only need the classes without typedFetch.
Error Class API
All HTTP error classes extend BaseHttpError:
Instance Properties:
status- HTTP status code (literal type, e.g.404)statusText- HTTP status text (literal type, e.g."Not Found")headers- ResponseHeadersobjectname- Error class name (e.g."NotFoundError")
Instance Methods:
json<T = unknown>()- Parse error response body as JSONtext()- Parse as textblob()- Parse as BlobarrayBuffer()- Parse as ArrayBufferclone()- Clone the error for multiple body reads
Static Properties:
status- HTTP status codestatusText- HTTP status text
Inspiration
Inspired by Go's error handling philosophy where errors are values:
result, err := http.Get("https://api.example.com/users")
if err != nil {
return err
}const { response, error } = await typedFetch<User[]>("/api/users");
if (error) {
return error;
}License
MIT
