From eb0efcd10dc612871750bde800baa59e59c1e746 Mon Sep 17 00:00:00 2001 From: Ajai Shankar <328008+ajaishankar@users.noreply.github.com> Date: Tue, 8 Oct 2024 00:47:08 -0500 Subject: [PATCH] feat!: simplify extensions --- README.md | 59 ++++++----- package-lock.json | 4 +- package.json | 2 +- src/__tests__/extend.test.ts | 96 +++++++++-------- src/base.ts | 8 +- src/extend.ts | 199 ++++++++++++++++++++++------------- 6 files changed, 216 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index d400ed0..359cc18 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ type Register = z.infer type Register = { email: string; password: string; - passwordConfirm: string; + confirm: string; } */ ``` @@ -473,40 +473,47 @@ export class EmailType extends types.StringType { ### Adding schema extensions -The following adds a *min* method to the StringType. +The following adds some extension to the StringType. + +An extension method takes one or more parameters and returns a refinement. ```ts -import { type MessageOverride, types } from "pukka"; - -export class StringExtensions extends types.StringType { - min(length: number, override?: MessageOverride) { - return super.extend( - "min", - [length], // extension params for introspection - override, - (ctx, value) => - value.length >= length || - ctx.issue(`Value must be at least ${length} characters`), - ); - } -} +import { Extensions } from "pukka"; + +const StringExtensions1 = Extensions.for(types.StringType, { + min: (length: number) => (ctx, data) => + data.length >= length || + ctx.issue(`Value must be at least ${length} characters`), + max: ..., + contains: ..., + matches: ..., +}); + +// or async - say check username availability +const StringExtensions2 = Extensions.forAsync(types.StringType, { + username: () => async (ctx, data) => ... +}); ``` ### Register and use the new type and extension ```ts -import { applyExtensions, registerType, z as zee } from "pukka"; +import { Extensions, registerType, z as zee } from "pukka"; -const withStringExtensions = applyExtensions( - types.StringType, - StringExtensions, -); +// combine extensions for a type +const StringExtensions = { + ...StringExtensions1, + ...StringExtensions2, +}; + +// and apply +const extendedString = Extensions.apply(types.StringType, StringExtensions); export const z = { ...zee, email: registerType(EmailType), - string: withStringExtensions(zee.string), + string: extendedString(zee.string), }; const Register = z @@ -526,17 +533,17 @@ const Register = z The parameters passed to an extension can be retrieved in a typesafe manner. -This allows extension authors to easily implement say a pukka-openapi library. +This allows extension authors to easily implement say a `pukka-openapi` library. ```ts -import { getExtensionParams } from "pukka"; +import { Extensions } from "pukka"; -const min = getExtensionParams( +const min = Extensions.getParams( Register.properties.password, StringExtensions, "min", -); +); // [length: number] ``` ## Gotchas diff --git a/package-lock.json b/package-lock.json index 799e817..c658c33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pukka", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pukka", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "devDependencies": { "@biomejs/biome": "^1.8.3", diff --git a/package.json b/package.json index 9f72cdb..93c5f5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pukka", - "version": "1.3.0", + "version": "1.4.0", "description": "Typescript schema-first zod compatible hyper validation", "repository": { "type": "git", diff --git a/src/__tests__/extend.test.ts b/src/__tests__/extend.test.ts index dac9ffe..dfe184b 100644 --- a/src/__tests__/extend.test.ts +++ b/src/__tests__/extend.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, test } from "vitest"; -import type { MessageOverride } from "../base"; -import { applyExtensions, getExtensionParams as params } from "../extend"; +import { Type } from "../base"; +import { Extensions } from "../extend"; import { type Path, registerIssues } from "../issue"; import { pukka } from "../pukka"; -import { StringType } from "../types"; +import { NumberType, StringType } from "../types"; import { assertFail } from "./assert"; +const params = Extensions.getParams; + const STRING_ISSUES = registerIssues({ min_length: (length: number, path?: Path) => { return `Value should be ${length} or more characters long`; @@ -15,50 +17,43 @@ const STRING_ISSUES = registerIssues({ const { min_length, username_taken } = STRING_ISSUES; -class StringExtensions1 extends StringType { - min(length: number, override?: MessageOverride) { - return super.extend("min", [length], override, (ctx, value) => { - value.length >= length || ctx.issue(min_length(length, ctx.path)); - }); - } -} - -class StringExtensions2 extends StringType { - max(length: number, override?: MessageOverride) { - return super.extend("max", [length], override, (ctx, value) => { - return ( - value.length <= length || - ctx.issue(`Value should be less than ${length} characters long`) - ); - }); - } -} - -class StringExtensions3 extends StringType { - username(override?: MessageOverride) { - return super.extendAsync("username", [], override, (ctx, value) => { - return Promise.resolve( - value === "homer" ? ctx.issue(username_taken(value)) : true, - ); - }); - } -} - -const withStringExtensions = applyExtensions( - StringType, - StringExtensions1, - StringExtensions2, - StringExtensions3, -); +const StringExtensions1 = Extensions.for(StringType, { + min: (length: number) => (ctx, value) => + value.length >= length || ctx.issue(min_length(length, ctx.path)), +}); + +const StringExtensions2 = Extensions.forAsync(StringType, { + username: () => async (ctx, value) => + value === "homer" ? ctx.issue(username_taken(value)) : true, +}); + +const InvalidStringExtension = Extensions.for(StringType, { + optional: () => (ctx) => ctx.issue("nope"), +}); + +const GenericExtension = Extensions.for(Type, { + description: (desc: string) => (ctx, value) => true, +}); + +const StringExtensions = { + ...StringExtensions1, + ...StringExtensions2, + ...InvalidStringExtension, + ...GenericExtension, +}; + +const extendedString = Extensions.apply(StringType, StringExtensions); +const extendedNumber = Extensions.apply(NumberType, GenericExtension); const z = { ...pukka, - string: withStringExtensions(pukka.string), + string: extendedString(pukka.string), + number: extendedNumber(pukka.number), }; describe("extend", () => { it("should be of correct type", () => { - expect(z.string().min(2)).toBeInstanceOf(StringType); + expect(z.string().min(2).username()).toBeInstanceOf(StringType); }); it("should clone", () => { @@ -67,12 +62,25 @@ describe("extend", () => { expect(b).not.toBe(a); }); + it("should not extend existing method", () => { + const a = z.string().optional(); + expect(a.isOptional).toBe(true); + expect(a.safeParse(undefined).success).toBe(true); + }); + it("should capture params", async () => { const name = z.string().min(2); - expect(params(name, StringExtensions1, "min")).toEqual([2]); - expect(params(name, StringExtensions3, "username")).toBeUndefined(); - const name2 = name.username(); - expect(params(name2, StringExtensions3, "username")).toEqual([]); + expect(params(name, StringExtensions, "min")).toEqual([2]); + expect(params(name, StringExtensions, "username")).toBeUndefined(); + const name2 = name.username().min(2); + expect(params(name2, StringExtensions, "username")).toEqual([]); + }); + + test("generic extension", () => { + const str = z.string().description("string"); + const num = z.number().description("number"); + expect(params(str, GenericExtension, "description")).toEqual(["string"]); + expect(params(num, GenericExtension, "description")).toEqual(["number"]); }); it("should invoke extensions", async () => { diff --git a/src/base.ts b/src/base.ts index 2cc1fd5..1b27c47 100644 --- a/src/base.ts +++ b/src/base.ts @@ -68,8 +68,8 @@ export type CoreIssueOverrides = { required_error?: ValidationResultOverride; }; -export type MessageOverride = { - message: ValidationResultOverride; +export type MessageOverride = { + message: ValidationResultOverride; }; export type ParseSuccess = { @@ -533,7 +533,7 @@ export abstract class Type extends BaseType { protected extend( name: keyof this, params: any[], - override: { message: ValidationResultOverride } | undefined, + override: MessageOverride | undefined, validator: Validator, ) { return this.addExtension(false, name, params, override?.message, validator); @@ -542,7 +542,7 @@ export abstract class Type extends BaseType { protected extendAsync( name: keyof this, params: any[], - override: { message: ValidationResultOverride } | undefined, + override: MessageOverride | undefined, validator: AsyncValidator, ) { return this.addExtension(true, name, params, override?.message, validator); diff --git a/src/extend.ts b/src/extend.ts index dee05e1..924d533 100644 --- a/src/extend.ts +++ b/src/extend.ts @@ -1,8 +1,39 @@ -import type { BaseType, CoreIssueOverrides, MessageOverride } from "./base"; +import type { + AsyncValidator, + BaseType, + CoreIssueOverrides, + Infer, + MessageOverride, + Validator, +} from "./base"; import { internalType } from "./internal"; type TypeConstructor = new (...args: any[]) => T; +type AbstractTypeConstructor = abstract new ( + ...args: any[] +) => T; + +type SyncExtensions = { + [name: string]: (...args: any[]) => Validator; +}; + +type AsyncExtensions = { + [name: string]: (...args: any[]) => AsyncValidator; +}; + +type Extensions = { + [name: string]: ((...args: any[]) => Validator | AsyncValidator) & { + async: boolean; + }; +}; + +export type Extended>> = T & { + [K in Exclude]: ( + ...args: [...Parameters, override?: MessageOverride>] + ) => Extended; +}; + /** * Helper to register a type with a constructor taking a single object parameter * @@ -17,84 +48,102 @@ export function registerType T>( (new ctor(param) as I).issues(param); } -export type Extended = any>( - fn: F, -) => (...args: Parameters) => R; +function extensions< + T extends BaseType, + C extends AbstractTypeConstructor, + X extends SyncExtensions>>, +>(ctor: C, extensions: X) { + for (const x of Object.values(extensions)) { + (x as any).async = false; + } + return extensions as { + [K in keyof X]: X[K] & { async: false }; + }; +} -/** - * Get an extension's parameters for codegen tools say pukka-openapi - * ```ts - * const s = z.string().minLength(2) - * expect(getExtensionParams(s, StringExtensions, "minLength")).toEqual([2]) - * ``` - */ -export function getExtensionParams< - X extends TypeConstructor, - M extends Exclude< - keyof InstanceType, - keyof BaseType | "refine" | "refineAsync" - >, ->(type: BaseType, ctor: X, name: M) { - type O = MessageOverride; - type T = InstanceType; - type P = T[M] extends (...args: any[]) => any ? Parameters : never; - type R = Required

extends [...infer H, infer L extends O] ? H : P; - return internalType(type).getExtensionParams(name as string) as R | undefined; +function asyncExtensions< + T extends BaseType, + C extends AbstractTypeConstructor, + X extends AsyncExtensions>>, +>(ctor: C, extensions: X) { + for (const x of Object.values(extensions)) { + (x as any).async = true; + } + return extensions as { + [K in keyof X]: X[K] & { async: true }; + }; } -/** - * Apply extensions to a type, this updates the type's prototype - * ```ts - * const extendedString = applyExtensions(StringType, StringExtensions); - * const x = { - * ...z, - * string: extendedString(z.string), - * }; - * const s = x.string().minLength(2) // minLength from StringExtensions - * ``` - */ -export function applyExtensions( - Base: B, - Ext1: X1, -): Extended & InstanceType>; -export function applyExtensions< - B extends TypeConstructor, - X1 extends B, - X2 extends B, ->( - Base: B, - Ext1: X1, - Ext2: X2, -): Extended & InstanceType & InstanceType>; -export function applyExtensions< - B extends TypeConstructor, - X1 extends B, - X2 extends B, - X3 extends B, ->( - Base: B, - Ext1: X1, - Ext2: X2, - Ext3: X3, -): Extended< - InstanceType & InstanceType & InstanceType & InstanceType ->; -export function applyExtensions( - Base: B, - ...extensions: any[] -) { - for (const ext of extensions) { - const ctorProps = Object.getOwnPropertyDescriptors(Base.prototype); - for (const name of Object.getOwnPropertyNames(ext.prototype)) { - if (!ctorProps[name]) { - Object.defineProperty( - Base.prototype, - name, - Object.getOwnPropertyDescriptor(ext.prototype, name)!, - ); - } +function applyExtensions< + T extends BaseType, + C extends TypeConstructor, + X extends Extensions>>, +>(ctor: C, extensions: X) { + const descriptors = new Set(); + let proto = ctor.prototype; + while (proto) { + for (const name of Object.getOwnPropertyNames(proto)) { + descriptors.add(name); + } + proto = Object.getPrototypeOf(proto); + } + + for (const [name, fn] of Object.entries(extensions)) { + if (descriptors.has(name)) { + continue; } + Object.defineProperty(ctor.prototype, name, { + value: function (this: any, ...args: any[]) { + const lastArg = args[args.length - 1]; + const hasOverride = lastArg?.message != null; + const override = hasOverride ? lastArg : undefined; + const params = hasOverride ? args.slice(0, -1) : args; + const validator = fn(...params); + if (fn.async) { + return this.extendAsync(name, params, override, validator); + } + return this.extend(name, params, override, validator); + }, + }); } - return (fn: any) => fn as any; + type I = InstanceType; + type Ext = Extended; + + return I>(fn: F) => + fn as (...args: Parameters) => I & Ext; } + +function getExtensionParams< + T extends BaseType, + X extends Extensions>, + M extends keyof X, +>(type: T, ext: X, name: M) { + type P = Parameters; + return internalType(type).getExtensionParams(name as string) as P | undefined; +} + +export const Extensions = { + for: extensions, + forAsync: asyncExtensions, + /** + * Apply extensions to a type, this updates the type's prototype + * ```ts + * const extendedString = Extensions.apply(StringType, StringExtensions); + * const x = { + * ...z, + * string: extendedString(z.string), + * }; + * const s = x.string().min(2) // min from StringExtensions + * ``` + */ + apply: applyExtensions, + /** + * Get an extension's parameters for codegen tools say pukka-openapi + * ```ts + * const s = z.string().min(2) + * expect(getExtensionParams(s, StringExtensions, "min")).toEqual([2]) + * ``` + */ + getParams: getExtensionParams, +};