@ayonli/jsext
Version:
A JavaScript extension package for building strong and modern applications.
310 lines (277 loc) • 9.9 kB
text/typescript
/**
* Decorator functions that can be used to validate the input and output of class
* methods and guarantee type safety at runtime, include `param`, `returns` and
* `throws`, based on [Zod](https://zod.dev).
* @module
*/
import { z } from "zod";
import type { MethodDecorator } from "../types.ts";
const _source = Symbol.for("source");
const _params = Symbol.for("params");
const _returns = Symbol.for("returns");
const _throws = Symbol.for("throws");
function refinedError(err: any, ctorOpt: Function) {
if (err instanceof z.ZodError) {
let issue = err.issues[0]! as z.ZodIssue;
if (issue.code === "invalid_union") {
// report the first error encountered
issue = issue.unionErrors[0]!.issues[0]! as any;
}
const path = issue.path.join(".");
const _message = issue.message[0]!.toLowerCase() + issue.message.slice(1);
const message = `validation failed at ${path}: `
+ _message.replace(/^input not instance of/, "not an instance of");
// @ts-ignore cause is not available in new ECMA standard
err = new TypeError(message, { cause: err });
Error.captureStackTrace?.(err, ctorOpt);
}
return err;
}
function decorate(
target: any,
prop: string | undefined = void 0,
desc: TypedPropertyDescriptor<any> | null = null
): (...arg: any[]) => any {
const fn: (...arg: any[]) => any = desc ? desc.value : target;
if (_source in fn) {
return fn;
}
const originFn = fn;
const fnName: string = prop
|| (typeof target === "function" ? target.name : "")
|| "anonymous";
void fnName; // not used yet
const newFn = (function (this: any, ...args: any[]) {
const paramsDef = newFn[_params] as { type: z.ZodType; name?: string | undefined; }[] | undefined;
const returnDef = newFn[_returns] as { type: z.ZodType; name: string; } | undefined;
const throwDef = newFn[_throws] as { type: z.ZodType; name: string; } | undefined;
if (paramsDef?.length) {
const keyedArgs: { [name: string]: any; } = {};
const params = paramsDef.map((item, index) => {
const name = item.name || `arg${index}`;
keyedArgs[name] = args[index];
return { ...item, name };
});
for (let i = Object.keys(keyedArgs).length; i < args.length; i++) {
keyedArgs[`arg${i}`] = args[i];
}
try {
for (const { name, type } of params) {
keyedArgs[name] = type.parse(keyedArgs[name], {
path: ["parameter " + name],
});
}
} catch (err) {
throw refinedError(err, newFn);
}
args = Object.values(keyedArgs);
}
const handleError = (
err: unknown,
onrejected: ((...args: any[]) => any) | null = null
) => {
if (err instanceof z.ZodError) {
throw refinedError(err, onrejected ?? newFn);
} else if (throwDef) {
try {
err = throwDef.type.parse(err, { path: [throwDef.name] });
} catch (_err) {
err = refinedError(_err, onrejected ?? newFn);
}
throw err;
} else {
throw err;
}
};
try {
let returns = originFn.apply(this, args);
if (returnDef) {
returns = returnDef.type.parse(returns, { path: [returnDef.name] });
}
if (returns && typeof returns === "object" && typeof returns.then === "function") {
return Promise.resolve(returns).catch(function catcher(err: any) {
handleError(err, catcher);
});
} else {
return returns;
}
} catch (err) {
handleError(err);
}
}) as any;
newFn[_source] = originFn;
Object.defineProperty(newFn,
"name",
Object.getOwnPropertyDescriptor(originFn, "name") as PropertyDescriptor);
Object.defineProperty(newFn,
"length",
Object.getOwnPropertyDescriptor(originFn, "length") as PropertyDescriptor);
Object.defineProperty(newFn, "toString", {
configurable: true,
enumerable: false,
writable: true,
value: originFn.toString.bind(originFn),
});
if (desc) {
return (desc.value = newFn);
} else {
return newFn;
}
};
/**
* A decorator that restrains the input arguments of the method at runtime, based
* on [Zod](https://zod.dev).
*
* NOTE: Although this decorator accepts a parameter name, it is not bound to the
* actually parameter in the method definition, it is only used for the error
* message. The parameter is bound by the appearance order of the decorator.
*
* @example
* ```ts
* import { param } from "@ayonli/jsext/class/decorators";
* import { z } from "zod";
*
* class Calculator {
* \@param("a", z.number())
* \@param("b", z.number())
* add(a: number, b: number) {
* return a + b;
* }
* }
*
* const calc = new Calculator();
* // \@ts-ignore for demonstration
* console.log(calc.add("2", 3));
* // throws:
* // TypeError: validation failed at parameter a: expected number, received string
* ```
*/
export function param<T extends z.ZodType>(name: string, type: T): MethodDecorator;
export function param<T extends z.ZodType>(type: T): MethodDecorator;
export function param<T extends z.ZodType>(
arg1: string | T,
arg2: T | undefined = undefined
): MethodDecorator {
let name: string | undefined;
let type: T;
if (typeof arg1 === "string") {
name = arg1;
type = arg2 as T;
} else {
name = undefined;
type = arg1;
}
return (...args: any[]) => {
if (typeof args[1] === "object") { // new ES decorator since TypeScript 5.0
const [target, context] = args as [Function, DecoratorContext];
const fn = decorate(target, context.name as string) as any;
const params = (fn[_params] ??= []) as { type: any; name?: string | undefined; }[];
params.unshift({ type, name });
return fn;
} else {
const [target, prop, desc] = args as [any, string, TypedPropertyDescriptor<any>];
const fn = decorate(target, prop, desc) as any;
const params = (fn[_params] ??= []) as { type: any; name?: string | undefined; }[];
params.unshift({ type, name });
return desc;
}
};
}
/**
* A decorator that restrains the return value of the method at runtime, based
* on [Zod](https://zod.dev).
*
* @example
* ```ts
* // regular function
* import { returns } from "@ayonli/jsext/class/decorators";
* import { z } from "zod";
*
* class Calculator {
* \@returns(z.number())
* times(a: number, b: number) {
* return String(a * b);
* }
* }
*
* const calc = new Calculator();
* console.log(calc.times(2, 3));
* // throws:
* // TypeError: validation failed at return value: expected number, received string
* ```
*
* @example
* ```ts
* // async function
* import { returns } from "@ayonli/jsext/class/decorators";
* import { z } from "zod";
*
* class Calculator {
* \@returns(z.promise(z.number()))
* async times(a: number, b: number) {
* await new Promise(resolve => setTimeout(resolve, 100));
* return String(a * b);
* }
* }
*
* const calc = new Calculator();
* console.log(await calc.times(2, 3));
* // throws:
* // TypeError: validation failed at return value: expected number, received string
* ```
*/
export function returns<T extends z.ZodType>(type: T): MethodDecorator {
return (...args: any[]) => {
if (typeof args[1] === "object") { // new ES decorator since TypeScript 5.0
const [target, context] = args as [Function, DecoratorContext];
const fn = decorate(target, context.name as string) as any;
fn[_returns] = { type, name: "return value" };
return fn;
} else {
const [target, prop, desc] = args as [any, string, TypedPropertyDescriptor<any>];
const fn = decorate(target, prop, desc) as any;
fn[_returns] = { type, name: "return value" };
return desc;
}
};
}
/**
* A decorator that restrains the thrown value of the method at runtime, based
* on [Zod](https://zod.dev).
*
* @example
* ```ts
* import { throws } from "@ayonli/jsext/class/decorators";
* import { z } from "zod";
*
* class Calculator {
* \@throws(z.instanceof(RangeError))
* div(a: number, b: number) {
* if (b === 0) {
* throw new TypeError("division by zero");
* }
* return a / b;
* }
* }
*
* const calc = new Calculator();
* console.log(calc.div(2, 0));
* // throws:
* // TypeError: validation failed at thrown value: not an instance of RangeError
* ```
*/
export function throws<T extends z.ZodType>(type: T): MethodDecorator {
return (...args: any[]) => {
if (typeof args[1] === "object") { // new ES decorator since TypeScript 5.0
const [target, context] = args as [Function, DecoratorContext];
const fn = decorate(target, context.name as string) as any;
fn[_throws] = { type, name: "thrown value" };
return fn;
} else {
const [target, prop, desc] = args as [any, string, TypedPropertyDescriptor<any>];
const fn = decorate(target, prop, desc) as any;
fn[_throws] = { type, name: "thrown value" };
return desc ?? fn;
}
};
}