zagora
Version:
A minimalist & robust way to create type-safe and error-safe never throwing functions & libraries in TypeScript - with input/output validation and typed errors. Based on StandardSchema-compliant validation libraries. No batteries, no routers, it's just fu
669 lines (589 loc) • 21.8 kB
text/typescript
// SPDX-License-Identifier: Apache-2.0
import type { StandardSchemaV1 } from "@standard-schema/spec";
import type {
MaybeAsyncValidateError,
MaybeAsyncValidateOutput,
OverloadedByPrefixes,
ZagoraBaseResult,
ZagoraConfig,
ZagoraErrorHelpers,
ZagoraInferInput,
ZagoraMetadata,
} from "./types.ts";
import { createDualResult, ZagoraError } from "./utils.ts";
export * from "./types.ts";
export * from "./utils.ts";
export function zagora(): Zagora<null, null, null, undefined>;
export function zagora<C extends ZagoraConfig>(
config: C
): Zagora<null, null, null, C>;
export function zagora<C extends ZagoraConfig>(
config?: C
): Zagora<null, null, null, C | undefined> {
return new Zagora(config);
}
export class Zagora<
InputSchema extends StandardSchemaV1 | null = null,
Output extends StandardSchemaV1 | null = null,
ErrSchema extends Record<string, StandardSchemaV1> | null = null,
Config extends ZagoraConfig | undefined = undefined,
> {
private _inputSchema: InputSchema | null = null;
private _outputSchema: Output | null = null;
private _errorSchema: ErrSchema | null = null;
private _config: Config;
"~zagora": ZagoraMetadata;
constructor(config?: Config) {
this._errorSchema = null;
this._config = (config || undefined) as Config;
}
// Accept a single schema - object, tuple, primitive, etc.
input<T extends StandardSchemaV1>(
schema: T
): Zagora<T, Output, ErrSchema, Config> {
const next = new Zagora<T, Output, ErrSchema, Config>(this._config);
(next as any)._inputSchema = schema;
(next as any)._outputSchema = this._outputSchema;
(next as any)._errorSchema = this._errorSchema;
this["~zagora"] = {
inputSchema: schema,
outputSchema: this._outputSchema,
errorSchema: this._errorSchema,
handlerFn: null,
};
return next;
}
output<NewOut extends StandardSchemaV1>(schema: NewOut) {
const next = new Zagora<InputSchema, NewOut, ErrSchema, Config>(
this._config
);
(next as any)._inputSchema = this._inputSchema;
(next as any)._outputSchema = schema;
(next as any)._errorSchema = this._errorSchema;
this["~zagora"] = {
inputSchema: this._inputSchema,
outputSchema: schema,
errorSchema: this._errorSchema,
handlerFn: null,
};
return next;
}
errors<NewErr extends Record<string, StandardSchemaV1>>(
schema: NewErr
): Zagora<InputSchema, Output, NewErr, Config> {
const next = new Zagora<InputSchema, Output, NewErr, Config>(this._config);
(next as any)._inputSchema = this._inputSchema;
(next as any)._outputSchema = this._outputSchema;
(next as any)._errorSchema = schema;
this["~zagora"] = {
inputSchema: this._inputSchema,
outputSchema: this._outputSchema,
errorSchema: schema,
handlerFn: null,
};
return next;
}
handler<
IS extends StandardSchemaV1 = InputSchema extends StandardSchemaV1
? InputSchema
: never,
OutputArgs = ZagoraInferInput<IS>,
>(
impl: ErrSchema extends Record<string, StandardSchemaV1>
? Config extends { errorsFirst: true }
? OutputArgs extends readonly any[]
? (...args: [ZagoraErrorHelpers<ErrSchema>, ...OutputArgs]) => any
: (errors: ZagoraErrorHelpers<ErrSchema>, arg: OutputArgs) => any
: OutputArgs extends readonly any[]
? (...args: [...OutputArgs, ZagoraErrorHelpers<ErrSchema>]) => any
: (arg: OutputArgs, errors: ZagoraErrorHelpers<ErrSchema>) => any
: OutputArgs extends readonly any[]
? (...args: OutputArgs) => any
: (arg: OutputArgs) => any
) {
const handlerFn = this.createHandlerAsync(impl);
this["~zagora"] = {
inputSchema: this._inputSchema,
outputSchema: this._outputSchema,
errorSchema: this._errorSchema,
handlerFn,
};
return Object.assign(handlerFn, this) as typeof handlerFn &
Zagora<IS, Output, ErrSchema, Config> & {
"~zagora": ZagoraMetadata<typeof handlerFn>;
};
}
handlerSync<
IS extends StandardSchemaV1 = InputSchema extends StandardSchemaV1
? InputSchema
: never,
OutputArgs = ZagoraInferInput<IS>,
>(
impl: ErrSchema extends Record<string, StandardSchemaV1>
? Config extends { errorsFirst: true }
? OutputArgs extends readonly any[]
? (...args: [ZagoraErrorHelpers<ErrSchema>, ...OutputArgs]) => any
: (errors: ZagoraErrorHelpers<ErrSchema>, arg: OutputArgs) => any
: OutputArgs extends readonly any[]
? (...args: [...OutputArgs, ZagoraErrorHelpers<ErrSchema>]) => any
: (arg: OutputArgs, errors: ZagoraErrorHelpers<ErrSchema>) => any
: OutputArgs extends readonly any[]
? (...args: OutputArgs) => any
: (arg: OutputArgs) => any
) {
const handlerFn = this.createHandlerSync(impl);
this["~zagora"] = {
inputSchema: this._inputSchema,
outputSchema: this._outputSchema,
errorSchema: this._errorSchema,
handlerFn,
};
return Object.assign(handlerFn, this) as typeof handlerFn &
Zagora<IS, Output, ErrSchema, Config> & {
"~zagora": ZagoraMetadata<typeof handlerFn>;
};
}
private createHandlerSync<
IS extends StandardSchemaV1 = InputSchema extends StandardSchemaV1
? InputSchema
: never,
>(impl: any) {
if (!this._inputSchema) {
throw new Error(".input(...) must be called first");
}
if (!this._outputSchema) {
throw new Error(".output(...) must be called first");
}
const inputSchema = this._inputSchema as StandardSchemaV1;
const outputSchema = this._outputSchema as StandardSchemaV1;
const errSchema = this._errorSchema;
// Create synchronous wrapper function
const wrapper = (...rawArgs: unknown[]) => {
// Validate input synchronously
const inputResult = this.validateInputSync(inputSchema, rawArgs);
if (inputResult.error) {
return createDualResult(null, inputResult.error, false);
}
// Call implementation
try {
// Add error helpers if error schema is defined
const finalArgs = errSchema
? (this._config as ZagoraConfig)?.errorsFirst
? [this.createErrorHelpers(errSchema), ...inputResult.args]
: [...inputResult.args, this.createErrorHelpers(errSchema)]
: inputResult.args;
const rawResult = (impl as any)(...finalArgs);
const isPromise = rawResult instanceof Promise;
if (isPromise) {
return createDualResult(
null,
new ZagoraError(
"Using `.handlerSync` only accepts synchronous functions"
),
false
);
}
// Check if result is a [data, error] tuple
if (Array.isArray(rawResult) && rawResult.length === 2) {
const [maybeOut, maybeErr] = rawResult as [unknown, unknown];
if (maybeErr != null) {
// Validate error against schemas if defined
if (errSchema) {
const { error: validatedError, isTyped } = this.validateError(
errSchema,
maybeErr,
true
);
if (isTyped) {
return createDualResult(null, validatedError, true);
}
return createDualResult(
null,
validatedError as ZagoraError,
true
);
}
// No error schemas defined, return error as ZagoraError if it's not already one
const zagoraError =
maybeErr instanceof ZagoraError
? maybeErr
: ZagoraError.fromCaughtError(
maybeErr,
"Untyped error returned"
);
return createDualResult(null, zagoraError, false);
}
// Validate successful output
const [res, err] = this.validateOutput(outputSchema, maybeOut, true);
if (err === null) {
return createDualResult(res, null, false);
}
return createDualResult(null, err, false);
}
// Direct result, validate as output
const [data, error] = this.validateOutput(
outputSchema,
rawResult,
true
);
if (error === null) {
return createDualResult(data, null, false);
}
return createDualResult(null, error, false);
} catch (err: unknown) {
// Handler threw an error - wrap in ZagoraError
const zagoraError = ZagoraError.fromCaughtError(
err,
"Handler threw unknown error"
);
return createDualResult(null, zagoraError, false);
}
};
type HandlerResult = ZagoraBaseResult<Output, ErrSchema>;
// Forward (call-site) signatures
type InputArgs = StandardSchemaV1.InferInput<IS>;
type SingleArg = InputArgs extends readonly any[] ? never : InputArgs;
type TupleArgs = InputArgs extends readonly any[] ? InputArgs : never;
type ForwardType = InputArgs extends readonly any[]
? OverloadedByPrefixes<
TupleArgs extends readonly any[] ? [...TupleArgs] : never,
HandlerResult
> &
((...args: TupleArgs) => HandlerResult)
: SingleArg extends Record<string, any>
? ((arg: SingleArg) => HandlerResult) &
OverloadedByPrefixes<[SingleArg], HandlerResult>
: ((arg: SingleArg) => HandlerResult) &
OverloadedByPrefixes<[SingleArg], HandlerResult>;
const forwardImpl = (...args: any[]) => wrapper(...(args as unknown[]));
const forward = forwardImpl as unknown as ForwardType;
return forward;
}
private createHandlerAsync<
IS extends StandardSchemaV1 = InputSchema extends StandardSchemaV1
? InputSchema
: never,
>(impl: any) {
if (!this._inputSchema) {
throw new Error(".input(...) must be called first");
}
if (!this._outputSchema) {
throw new Error(".output(...) must be called first");
}
const inputSchema = this._inputSchema as StandardSchemaV1;
const outputSchema = this._outputSchema as StandardSchemaV1;
const errSchema = this._errorSchema;
// Create asynchronous wrapper function
const wrapper = async (...rawArgs: unknown[]) => {
// Validate input
const inputResult = await this.validateInput(inputSchema, rawArgs);
if (inputResult.error) {
return createDualResult(null, inputResult.error, false);
}
// Call implementation
try {
// Add error helpers if error schema is defined
const finalArgs = errSchema
? (this._config as ZagoraConfig)?.errorsFirst
? [this.createErrorHelpers(errSchema), ...inputResult.args]
: [...inputResult.args, this.createErrorHelpers(errSchema)]
: inputResult.args;
let rawResult = (impl as any)(...finalArgs);
const isNotPromise = !(rawResult instanceof Promise);
if (isNotPromise) {
return createDualResult(
null,
new ZagoraError("Using `.handler` only accepts async functions"),
false
);
}
rawResult = await rawResult;
// Check if result is a [data, error] tuple
if (Array.isArray(rawResult) && rawResult.length === 2) {
const [maybeOut, maybeErr] = rawResult as [unknown, unknown];
if (maybeErr != null) {
// Validate error against schemas if defined
if (errSchema) {
const { error: validatedError, isTyped } =
await this.validateError(errSchema, maybeErr, false);
if (isTyped) {
return createDualResult(null, validatedError, true);
}
return createDualResult(
null,
validatedError as ZagoraError,
false
);
}
// No error schemas defined, return error as ZagoraError if it's not already one
const zagoraError =
maybeErr instanceof ZagoraError
? maybeErr
: ZagoraError.fromCaughtError(
maybeErr,
"Untyped error returned"
);
return createDualResult(null, zagoraError, false);
}
// Validate successful output
const [res, err] = await this.validateOutput(
outputSchema,
maybeOut,
false
);
if (err === null) {
return createDualResult(res, null, false);
}
return createDualResult(null, err, false);
}
// Direct result, validate as output
const [data, error] = await this.validateOutput(
outputSchema,
rawResult,
false
);
if (error === null) {
return createDualResult(data, null, false);
}
return createDualResult(null, error, false);
} catch (err: unknown) {
// Handler threw an error - wrap in ZagoraError
const zagoraError = ZagoraError.fromCaughtError(
err,
"Handler threw unknown error"
);
return createDualResult(null, zagoraError, false);
}
};
type HandlerResult = Promise<ZagoraBaseResult<Output, ErrSchema>>;
// Forward (call-site) signatures
type InputArgs = StandardSchemaV1.InferInput<IS>;
type SingleArg = InputArgs extends readonly any[] ? never : InputArgs;
type TupleArgs = InputArgs extends readonly any[] ? InputArgs : never;
type ForwardType = InputArgs extends readonly any[]
? OverloadedByPrefixes<
TupleArgs extends readonly any[] ? [...TupleArgs] : never,
HandlerResult
> &
((...args: TupleArgs) => HandlerResult)
: SingleArg extends Record<string, any>
? ((arg: SingleArg) => HandlerResult) &
OverloadedByPrefixes<[SingleArg], HandlerResult>
: ((arg: SingleArg) => HandlerResult) &
OverloadedByPrefixes<[SingleArg], HandlerResult>;
const forwardImpl = (...args: any[]) => wrapper(...(args as unknown[]));
const forward = forwardImpl as unknown as ForwardType;
return forward;
}
private validateInputSync(
inputSchema: StandardSchemaV1,
rawArgs: unknown[]
): { args: unknown[]; error?: ZagoraError } {
// Handle tuple defaults if needed
const processedArgs = this.handleTupleDefaults(inputSchema, rawArgs);
// Try tuple validation first
let result = inputSchema["~standard"].validate(processedArgs);
if (result instanceof Promise) {
throw new ZagoraError(
"Cannot use async input schema validation in handlerSync"
);
}
if (!result.issues) {
return { args: (result as any).value as unknown[] };
}
// Try single argument validation if tuple validation failed
const singleValue = processedArgs[0];
result = inputSchema["~standard"].validate(singleValue);
if (result instanceof Promise) {
throw new ZagoraError(
"Cannot use async input schema validation in handlerSync"
);
}
if (result.issues) {
return {
args: [],
error: ZagoraError.fromIssues(result.issues),
};
}
const validatedValue = (result as any).value;
const args = Array.isArray(validatedValue)
? validatedValue
: [validatedValue];
return { args };
}
private async validateInput(
inputSchema: StandardSchemaV1,
rawArgs: unknown[]
): Promise<{ args: unknown[]; error?: ZagoraError }> {
// Handle tuple defaults if needed
const processedArgs = this.handleTupleDefaults(inputSchema, rawArgs);
// Try tuple validation first
let result = inputSchema["~standard"].validate(processedArgs);
if (result instanceof Promise) {
result = await result;
}
if (!result.issues) {
return { args: (result as any).value as unknown[] };
}
// Try single argument validation if tuple validation failed
const singleValue = processedArgs[0];
result = inputSchema["~standard"].validate(singleValue);
if (result instanceof Promise) {
result = await result;
}
if (result.issues) {
return {
args: [],
error: ZagoraError.fromIssues(result.issues),
};
}
const validatedValue = (result as any).value;
const args = Array.isArray(validatedValue)
? validatedValue
: [validatedValue];
return { args };
}
private handleTupleDefaults(
schema: StandardSchemaV1,
rawArgs: unknown[]
): unknown[] {
// Check if this might be a tuple schema by examining the schema structure
const schemaAny = schema as any;
// Try to detect if this is a Zod tuple schema
if (schemaAny._def && schemaAny._def.type === "tuple") {
const tupleItems = schemaAny._def.items;
if (tupleItems && Array.isArray(tupleItems)) {
const result = [...rawArgs];
// Fill in defaults for missing elements
for (let i = rawArgs.length; i < tupleItems.length; i++) {
const itemSchema = tupleItems[i];
if (itemSchema && itemSchema.type === "default" && itemSchema._def) {
const defaultValue =
typeof itemSchema._def.defaultValue === "function"
? itemSchema._def.defaultValue()
: itemSchema._def.defaultValue;
result[i] = defaultValue;
}
}
return result;
}
}
return rawArgs;
}
// Define the validation result type once
private validateOutput<TIsSync extends boolean>(
outputSchema: StandardSchemaV1,
output: unknown,
isSync: TIsSync
): MaybeAsyncValidateOutput<TIsSync> {
const result = outputSchema["~standard"].validate(output);
if (result instanceof Promise) {
if (isSync) {
throw new ZagoraError(
"Cannot use async output schema validation in handlerSync"
);
}
return result.then((res) => {
if (res.issues) {
return [null, ZagoraError.fromIssues(res.issues)] as const;
}
return [(res as { value: any }).value, null];
}) as MaybeAsyncValidateOutput<TIsSync>;
}
if (result.issues) {
return [
null,
ZagoraError.fromIssues(result.issues),
] as MaybeAsyncValidateOutput<TIsSync>;
}
return [
(result as { value: any }).value,
null,
] as MaybeAsyncValidateOutput<TIsSync>;
}
private validateError<TIsSync extends boolean>(
errSchema: Record<string, StandardSchemaV1>,
maybeErr: unknown,
isSync: TIsSync
): MaybeAsyncValidateError<TIsSync> {
// Try to validate against each error schema
for (const [_key, errorSchema] of Object.entries(errSchema)) {
const result = errorSchema["~standard"].validate(maybeErr);
if (result instanceof Promise) {
if (isSync) {
throw new ZagoraError(
"Cannot use async error schema validation in handlerSync"
);
}
return result.then((res) => {
if (!res.issues) {
return { error: (res as any).value, isTyped: true };
}
return { error: maybeErr, isTyped: false };
}) as MaybeAsyncValidateError<TIsSync>;
}
if (!result.issues) {
return {
error: (result as any).value,
isTyped: true,
} as MaybeAsyncValidateError<TIsSync>;
}
}
// If no schema matched, return error as-is (will be marked as untyped)
return {
error: maybeErr,
isTyped: false,
} as MaybeAsyncValidateError<TIsSync>;
}
private createErrorHelpers(schema: Record<string, StandardSchemaV1>) {
// Helper to convert snake_case to PascalCase
const toPascalCase = (str: string) => {
return str
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
};
const helpers: any = {};
for (const [key, errorSchema] of Object.entries(schema)) {
helpers[key] = (error: any) => {
// Try different type values to auto-inject
const typeVariants = [
key, // e.g., "network" or "rate_limit"
`${toPascalCase(key)}Error`, // e.g., "NetworkError" or "RateLimitError"
`${key.toUpperCase()}_ERROR`, // e.g., "NETWORK_ERROR" or "RATE_LIMIT_ERROR"
];
let result: any;
let errorWithType: any;
// First try without injecting type (user might have provided it)
result = errorSchema["~standard"].validate(error);
if (result instanceof Promise) {
throw new ZagoraError(
"Synchronous error helpers don't support async schemas"
);
}
// If validation succeeded, use it
if (!result.issues) {
return [null, (result as any).value] as const;
}
// Try with auto-injected type variants
for (const typeValue of typeVariants) {
errorWithType = { ...error, type: typeValue };
result = errorSchema["~standard"].validate(errorWithType);
if (result instanceof Promise) {
throw new ZagoraError(
"Synchronous error helpers don't support async schemas"
);
}
if (!result.issues) {
return [null, (result as any).value] as const;
}
}
// If all attempts failed, throw error
throw new ZagoraError(
`Invalid error data for "errors.${key}": ${result.issues.map((i: any) => i.message).join(", ")}`
);
};
}
return helpers;
}
}