pukka

v1.5.0
Typescript schema-first zod compatible hyper validation
typescript schema validation type inference zod

version(scoped) codecov

pukka!

pukka is a Typescript schema-first, zod compatible, headless validation library.

It aims to completely separate schema from validation. With pukka, unlike other libraries, there is no need for field level validation chains.

You're free to do your validations, your way... in a single place!

pukka simplifies common data validation tasks - trim strings, data coercion, conditional validation, async validation, deeply nested field validation, external dependencies during validation, deserialize nested form data, render form fields with errors, schema reuse on browser and server, error message customization, internationalization, or even partial validation!

Designed to be extensible, pukka makes it easy to add new types or even implement field level zod like validators!

Install

npm install pukka

Usage

Define a schema as usual, and consolidate validations.

import { z } from "pukka";

const Register = z
  .object({
    email: z.string(),
    password: z.string(),
    confirm: z.string(),
  })
  .refine((ctx, data) => {
    ctx.issue(data.email.length === 0, "Please enter your email");
    ctx.issue(data.password.length < 8, "Password should be 8 or more characters");
    if (data.password !== data.confirm) {
      ctx.issue("Passwords don't match");
    }
  });

const data = Register.parse({ password: "secret" });

const { success, data, issues, input } = Register.safeParse({
  password: "secret",
});

On validation error, pukka automatically tags each field with its issues!

There is no need to explicity specify the error field path.

API

pukka has just a handful of APIs.

Every schema has the following methods.

schema.optional()
schema.nullable()

schema.refine((ctx, data) => {...})
schema.refineAsync(async (ctx, data) => {...})

schema.parse(input, options)
schema.parseAsync(input, options)

schema.safeParse(input, options)
schema.safeParseAsync(input, options)

And the following are available on the context.

schema.refine((ctx, data) => {
  data.foo.bar[0] // access a field

  ctx.path // ["foo", "bar", 0]
  ctx.issue("major issue") // raise an issue
  ctx.pathFor(data.foo) // ["foo"]
  ctx.isDefined(data.foo) // data.foo != null
  ctx.get<T>("baz") // property from parse options: s.parse(input, { baz: any })

  // and a few overloads for ctx.issue(...)
  ctx.issue(() => "major issue") // function (eg ParaglideJS - m.major_issue)
  ctx.issue(condition, "major issue") // raise issue only if condition is true
  ctx.issue("severe", "major issue") // issue with code & message
  ctx.issue("severe", "major issue", ["foo"]) // issue with code, message & path
})

Types

Though it's very easy to add types, the list of supported types is intentionally kept simple.

z.string()
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.number()
z.boolean()
z.enum(["ordered", "shipped"])
z.enum(["ordered", "shipped"] as const) // as needed
z.file()
z.literal(...)
  z.literal(true)
  z.literal(1)
  z.literal("type")
z.record(z.number()) // { jan: 1, feb: 2 }
z.array(z.string())
z.array(z.string(), { coerce: true }) // parse("a") => [ "a" ]
z.object({
  name: z.string(),
  age: z.number(),
  interests: z.array(z.string())
})
z.union([
  z.object({
    success: z.literal(true),
    data: z.object({
      name: z.string(),
    }),
  }),
  z.object({
    success: z.literal(false),
    issues: z.array(
      z.object({
        code: z.string(),
        message: z.string(),
        path: z.array(z.number()),
      }),
    ),
  }),
]);

Parse

As with zod, call parse or safeParse.

On error, pukka returns a list of issues, and also the input and issues for each field!

This makes it easy to render say a Remix form with field level errors.

safeParse / safeParseAsync

const { success, data, issues, input } = Register.safeParse({
  password: "secret",
});

if (success) {
  data.email;
} else {
  issues;
  input.email.value;
  input.email.issues;
}

parse / parseAsync

try {
  const data = Register.parse({ password: "secret" });
} catch (e) {
  if (e instanceof ParseError) {
    e.issues;
    e.input(Register);
  }
}

issues

issues = [
  { path: ["email"], code: "required", message: "Value is required" },
  { path: ["email"], code: "custom", message: "Please enter your email", },
  { path: ["password"], code: "custom", message: "Password should be 8 or more characters", },
  { path: ["confirm"], code: "required", message: "Value is required", },
  { path: ["confirm"], code: "custom", message: "Passwords don't match", },
];

input

Field level issues and values, to easily render forms.

input = {
  email: {
    issues: [
      { code: "required", message: "Value is required", },
      { code: "custom", message: "Please enter your email", },
    ],
  },
  password: {
    parsed: "secret",
    value: "secret",
    issues: [
      { code: "custom", message: "Password should be 8 or more characters", },
    ],
  },
  confirm: {
    issues: [
      { code: "required", message: "Value is required", },
      { code: "custom", message: "Passwords don't match", },
    ],
  },
  issues: [],
};

Partial validation

With pukka, you don't need to run the whole validation suite to validate a field on change.

const handleEmailChange = (e) => {
  const { issues } = Register.properties.email.safeParse(e.target.value);
};

// or if the schema is unknown (a generic ui form library)

import { getPathType } from "pukka";

const handleEmailChange = (e) => {
  const email = getPathType(schema, ["email"]);
  const { issues } = email.safeParse(e.target.value);
};

Infer

Typescript first - pukka!

type Register = z.infer<typeof Register>

/*
type Register = {
  email: string;
  password: string;
  confirm: string;
}
*/

Built-in validators

Sorry, none.

Because validator.js probably has more than what we ever need.

import isEmail from "validator/lib/isEmail";

isEmail(data.email) || ctx.issue("That's not a valid email");

Runtime context

pukka supports passing additional context to parse for complex validations.

For example, to check if an email is already registered

  1. On the browser, hit an API endpoint
  2. On the server, use drizzle to query the users table

The same static pukka schema works for both!

type IsEmailRegistered = (email: string) => Promise<boolean>;

const Register = z
  .object({
    email: z.string(),
    password: z.string(),
    confirm: z.string(),
  })
  .refineAsync((ctx, data) => {
    if (data.email.length) {
      const isEmailRegistered = ctx.get<IsEmailRegistered>("isEmailRegistered");

      if (await isEmailRegistered(data.email)) {
        ctx.issue("Email is already registered");
      }
    }
  });

On the browser

const result = Register.safeParse(input, {
  isEmailRegistered: (email: string) => fetch(`/email/check?${email}`).then(...)
});

And on the server

const result = Register.safeParse(data, {
  isEmailRegistered: (email: string) => db.select().from(users).where(...)
});

Error customization

pukka has excellent support for error message customization and internationalization.

In fact it was kind of born from a long running issue in zod.

It also plays real nice with ParaglideJS i18n.

The core issues invalid_type_error, required_error can be overriden at a field level.

const Register = z.object({
  email: z.string({ required_error: "Please enter your email" }),
  password: z.string({ required_error: m.password_required }), // ParaglideJS
})

For schemas, instead of hardcoding errors, register the issues.

const REGISTRATION_ISSUES = registerIssues({
  email_required: () => "Please enter your email",
  password_length: (length: number) => `Password should be ${length} or more characters`,
  password_mismatch: () => "Passwords don't match",
});

const m = REGISTRATION_ISSUES;

const Register = z
  .object({
    email: z.string(),
    password: z.string(),
    confirm: z.string(),
  })
  .refine((ctx, data) => {
    data.email.length > 0 || ctx.issue(m.email_required());
    data.password.length >= 8 || ctx.issue(m.password_length(8));
    data.password !== data.confirm && ctx.issue(m.password_mismatch());
  });

Customize (pardon the Spanish)

REGISTRATION_ISSUES.customize({
  email_required: () => "Por favor ingrese su correo electrónico",
  password_length: (length) => `La contraseña debe tener ${length} caracteres o más.`,
  password_mismatch: () => "Las contraseñas no coinciden",
});

You can also customize the three built-in core issues

import { CORE_ISSUES } from "pukka";

CORE_ISSUES.customize({
  invalid_type: (expected: string, input: unknown, path: Path) => "...",
  required: (input: undefined | null, path: Path) => "...",
  exception: (e: Error, path: Path) => "..."
});

Type coercion and sanitization

The following options can be passed to parse and can be overriden per schema

type ParseOptions = {
  string?: {
    coerce?: boolean; // anything other than objects & arrays to string
    trim?: boolean; // strings are trimmed by default, override it here
    empty?: boolean; // if false, empty strings result in a required error
  };
  number?: {
    coerce?: boolean; // string to number
  };
  boolean?: {
    coerce?: boolean; // truthy
  };
};

const schema = z.object({
  name: z.string({ trim: true }),
  interests: z.array(z.string(), { coerce: true })
})

schema.parse({
  string: { coerce: true, trim: false, empty: false },
  number: { coerce: true },
  boolean: { coerce: true }
})

Helpers

DeepObject.fromEntries(FormData | URLSearchParams)

Parse a deeply nested qs encoded form.

const form = new FormData();

form.append("addresses[0].state", "TX");
form.append("addresses[1].state", "CA");

const data = DeepObject.fromEntries(form);

data = { addresses: [{ state: "TX" }, { state: "CA" }] };

toParsedInput(data: unknown)

Convert valid data to parsed input, useful when rendering forms.

const data = toParsedInput({
  name: "pukka",
});

data = {
  name: { parsed: "pukka", value: "pukka", issues: [], },
  issues: [],
};

getDisplayName(path: (string|number)[])

Gets the display name for a path.

const label = getDisplayName(["orders", 0, "orderNumber"])
label = "Order Number"

Extensibility

pukka is designed to be extensible!

Adding a new type

It's very simple to add new types to pukka.

To add an email type, extend from StringType and override check and coerce methods.

import { type ParseContext, types } from "pukka";

import isEmail from "validator/lib/isEmail";

export class EmailType extends types.StringType {
  protected override check(ctx: ParseContext, input: unknown) {
    const res = super.check(ctx, input);
    return res !== true
      ? res
      : isEmail(input as string) || ctx.issue("Invalid email");
  }
  protected override coerce(ctx: ParseContext, input: unknown) {
    // doesn't make sense to coerce anything to email
    return undefined;
  }
}

Adding schema extensions

The following adds some extension to the StringType.

An extension method takes zero or more parameters and returns a refinement.

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: ...,
});

// or async - say check username availability
const StringExtensions2 = Extensions.forAsync(types.StringType, {
  username: () => async (ctx, data) => ...
});

Register and use the new type and extension


import { Extensions, registerType, z as zee } from "pukka";

// 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: extendedString(zee.string),
};

const Register = z
  .object({
    email: z.email(),
    password: z.string().min(8),
    confirm: z.string(),
  })
  .refine((ctx, data) => {
    if (ctx.isDefined(data.confirm) && data.password !== data.confirm) {
      ctx.issue("Passwords don't match");
    }
  });

Extension introspection

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.


import { Extensions } from "pukka";

const min = Extensions.getParams(
  Register.properties.password,
  StringExtensions,
  "min",
); // [length: number]

Gotchas

Checking for null input

pukka guarantees that the data passed to refine won't have null or undefined fields.

So data.field != null won't work as expected.

Whether input data was actually null can be checked with ctx.isDefined()

if (ctx.isDefined(data.password) && data.password.length < 8) {
  ctx.issue("Password should be 8 or more characters");
}

Destructuring

Rule of thumb - try to avoid!

pukka tracks property access using a proxy, and assigns ctx.issue() to the last accessed field.

So destructuring scalars won't work as expected.

schema.refine((ctx, data) => {
  const { email, password } = data;
  ctx.issue(email.length === 0, "Please enter your email") // won't work
  ctx.issue(data.email.length === 0, "Please enter your email") // works
})

Extensions and optional/nullable

Optional and nullable need to be specified last with extensions.

z.string().min(2).optional() // correct: string | undefined
z.string().optional().min(2) // incorrect: string

Inspiration (prior art)

zod - obviously, and valibot.

pukka favors simplicity over feature bloat.

That is, no lazy recursive types, intersection types, function types, pick, merge, input vs output schemas, pipes, preprocess, transform etc.

If all you want to do is validate forms and api payloads in a natural, typesafe way, do give pukka a try.

I am sure you'll love it - it's a lot of goodness packed in a tiny extensible codebase!

npm i pukka

Metadata

  • MIT
  • Whatever
  • Ajai Shankar
  • released 10/8/2024

Downloads

Maintainers