Skip to content

Commit

Permalink
feat!: simplify extensions
Browse files Browse the repository at this point in the history
  • Loading branch information
ajaishankar committed Oct 8, 2024
1 parent e69bcd6 commit eb0efcd
Show file tree
Hide file tree
Showing 6 changed files with 216 additions and 152 deletions.
59 changes: 33 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ type Register = z.infer<typeof Register>
type Register = {
email: string;
password: string;
passwordConfirm: string;
confirm: string;
}
*/
```
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
96 changes: 52 additions & 44 deletions src/__tests__/extend.test.ts
Original file line number Diff line number Diff line change
@@ -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`;
Expand All @@ -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<unknown>, {
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", () => {
Expand All @@ -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 () => {
Expand Down
8 changes: 4 additions & 4 deletions src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ export type CoreIssueOverrides = {
required_error?: ValidationResultOverride;
};

export type MessageOverride = {
message: ValidationResultOverride;
export type MessageOverride<T> = {
message: ValidationResultOverride<T>;
};

export type ParseSuccess<T> = {
Expand Down Expand Up @@ -533,7 +533,7 @@ export abstract class Type<T> extends BaseType {
protected extend(
name: keyof this,
params: any[],
override: { message: ValidationResultOverride<T> } | undefined,
override: MessageOverride<T> | undefined,
validator: Validator<T>,
) {
return this.addExtension(false, name, params, override?.message, validator);
Expand All @@ -542,7 +542,7 @@ export abstract class Type<T> extends BaseType {
protected extendAsync(
name: keyof this,
params: any[],
override: { message: ValidationResultOverride<T> } | undefined,
override: MessageOverride<T> | undefined,
validator: AsyncValidator<T>,
) {
return this.addExtension(true, name, params, override?.message, validator);
Expand Down
Loading

0 comments on commit eb0efcd

Please sign in to comment.