@beignet/core
Version:
Core framework primitives for Beignet
192 lines (174 loc) • 5.46 kB
text/typescript
/**
* Error catalog and AppError definitions for Beignet
*/
import type { StandardSchemaV1 } from "@standard-schema/spec";
type ErrorDetailsSchema = StandardSchemaV1<unknown, unknown>;
const APP_ERROR_BRAND = Symbol.for("beignet.AppError");
/**
* Definition for one application error catalog entry.
*/
export interface ErrorDef<
TDetails extends ErrorDetailsSchema | undefined =
| ErrorDetailsSchema
| undefined,
> {
/** Unique, stable code used across layers, such as `"POST_NOT_FOUND"`. */
code: string;
/** HTTP status code returned when this error crosses the HTTP boundary. */
status: number;
/** Default human-readable message. */
message: string;
/**
* Optional schema for the structured details value.
*
* Beignet uses this for TypeScript inference. Details are not runtime
* validated by `AppError`; validate before constructing errors when needed.
* Details become public response data when the error crosses the HTTP
* boundary, so use app-owned safe fields instead of raw diagnostics.
*/
details?: TDetails;
}
/**
* App error catalog keyed by developer-friendly names.
*
* Keys are local identifiers used by `createAppError(...)`; the public stable
* error code lives on each `ErrorDef.code`.
*/
export type ErrorCatalog = Record<string, ErrorDef>;
/**
* Infer the details input type for an error definition.
*/
export type InferErrorDetails<TDef extends ErrorDef> = TDef extends {
details: ErrorDetailsSchema;
}
? StandardSchemaV1.InferInput<TDef["details"]>
: unknown;
/**
* Define an application error catalog without losing literal key and code types.
*/
export function defineErrors<const T extends ErrorCatalog>(defs: T): T {
return defs;
}
/**
* Application error thrown from use cases, policies, and route handlers.
*
* The server maps `AppError` instances to Beignet's standard error envelope and
* marks them as route-owned errors when the contract declares the catalog entry.
*/
export class AppError<TDef extends ErrorDef = ErrorDef> extends Error {
readonly [APP_ERROR_BRAND] = true;
/** Error definition from the catalog. */
readonly def: TDef;
/**
* Optional structured details for clients or UI.
*
* Beignet does not automatically redact route-owned error details. Keep
* provider errors, stack traces, secrets, and private content in `cause`,
* logs, or error reporting instead.
*/
readonly details?: InferErrorDetails<TDef>;
/**
* Optional HTTP response headers set when this error crosses the HTTP
* boundary, such as `Retry-After` on a 429. Headers are public response
* data; only include values that are safe to expose.
*/
readonly headers?: Record<string, string>;
constructor(
def: TDef,
details?: InferErrorDetails<TDef>,
overrideMessage?: string,
options?: { cause?: unknown; headers?: Record<string, string> },
) {
super(overrideMessage ?? def.message, { cause: options?.cause });
this.name = "AppError";
this.def = def;
this.details = details;
this.headers = options?.headers;
}
/**
* Stable public error code.
*/
get code(): string {
return this.def.code;
}
/**
* HTTP status code associated with this error.
*/
get status(): number {
return this.def.status;
}
}
/**
* Callable helper for creating `AppError` instances from a catalog.
*/
export type AppErrorCreator<TCatalog extends ErrorCatalog> = {
/**
* Original catalog bound to this creator.
*/
catalog: TCatalog;
/**
* Create an `AppError` from a catalog key.
*/
<Key extends keyof TCatalog>(
key: Key,
options?: {
cause?: unknown;
details?: InferErrorDetails<TCatalog[Key]>;
headers?: Record<string, string>;
message?: string;
},
): AppError<TCatalog[Key]>;
};
/**
* Create a callable `AppError` helper bound to a specific catalog.
*
* The returned function validates the catalog key and preserves each entry's
* details type for `options.details`.
*/
export function createAppError<const T extends ErrorCatalog>(
catalog: T,
): AppErrorCreator<T> {
const appError = (<Key extends keyof T>(
key: Key,
options?: {
cause?: unknown;
details?: InferErrorDetails<T[Key]>;
headers?: Record<string, string>;
message?: string;
},
): AppError<T[Key]> => {
const def = catalog[key as keyof T] as T[typeof key] | undefined;
if (!def) {
throw new Error(`Unknown error catalog key: ${String(key)}`);
}
return new AppError<T[typeof key]>(
def,
options?.details,
options?.message,
{
cause: options?.cause,
headers: options?.headers,
},
);
}) as AppErrorCreator<T>;
appError.catalog = catalog;
return appError;
}
/**
* Check whether an unknown value is a Beignet `AppError`.
*/
export function isAppError(err: unknown): err is AppError<ErrorDef> {
if (err instanceof AppError) return true;
if (typeof err !== "object" || err === null) return false;
const value = err as Record<PropertyKey, unknown>;
if (value[APP_ERROR_BRAND] === true) return true;
if (value.name !== "AppError") return false;
const def = value.def;
if (typeof def !== "object" || def === null) return false;
const errorDef = def as Record<string, unknown>;
return (
typeof errorDef.code === "string" &&
typeof errorDef.status === "number" &&
typeof errorDef.message === "string"
);
}