api-payload-guard
v1.1.0
Published
Zero-dependency TypeScript API payload validator with nested schemas, sanitization, coercion, structured errors, and type inference.
Maintainers
Readme
api-payload-guard
Zero-dependency API payload validation for Node.js and TypeScript. Validate nested request bodies, sanitize unknown fields, normalize input, and return predictable errors without adding a large schema library.
Why use it?
- Zero runtime dependencies
- Nested object and array validation with precise paths such as
profile.address.zip - String, number, integer, boolean, array, object, null, and any types
- Length, range, pattern, enum, format, and collection constraints
- Email, URL, UUID, ISO date, and ISO datetime formats
- Unknown-field reject, strip, or preserve modes
- Safe opt-in coercion, defaults, normalization, transforms, and custom validators
- Prototype-pollution key protection and recursion limits
- Structured errors with custom or localized messages
- TypeScript inference from schemas
- Reusable compiled guards and assertion-style validation
- ESM support for Node.js 18 and newer
Install
npm install api-payload-guardQuick start
import { defineSchema, guardPayload } from "api-payload-guard";
const createUserSchema = defineSchema({
email: {
type: "string",
required: true,
trim: true,
lowercase: true,
format: "email"
},
age: {
type: "integer",
min: 13,
max: 120
},
role: {
type: "string",
enum: ["user", "admin"]
},
profile: {
type: "object",
schema: {
displayName: { type: "string", minLength: 2, maxLength: 50 },
website: { type: "string", format: "url", nullable: true }
}
},
tags: {
type: "array",
maxItems: 10,
uniqueItems: true,
items: { type: "string", trim: true, lowercase: true }
},
active: {
type: "boolean",
default: true
}
});
const result = guardPayload(
{
email: " [email protected] ",
age: 24,
role: "user",
profile: { displayName: "Dev" },
tags: [" Node.js ", "TypeScript"]
},
createUserSchema
);
if (result.valid) {
console.log(result.data);
// email is normalized, tags are normalized, and active is true
} else {
console.log(result.errors);
}A successful result is a discriminated union:
{
valid: true,
data: { /* validated and normalized fields */ },
errors: []
}A failed result never exposes partial data:
{
valid: false,
data: null,
errors: [
{
field: "profile.website",
code: "INVALID_FORMAT",
expected: "url",
received: "string",
message: "profile.website must be a valid url"
}
]
}Schema reference
Every field requires a type.
| Option | Applies to | Purpose |
| --- | --- | --- |
| required | all | Reject an omitted or undefined value |
| nullable | all | Allow null in addition to the declared type |
| default | all | Static value or zero-argument factory used when missing |
| enum | all | Allow only values in the supplied array |
| coerce | primitives | Override global coercion for this field |
| validate | all | Return true, false, or a custom error message |
| transform | all | Transform a value before type and constraint validation |
| message | all | One custom message or messages keyed by error code |
| minLength, maxLength | string | Limit string length |
| pattern | string | Match a RegExp or regular-expression string |
| format | string | email, url, uuid, iso-date, or iso-datetime |
| trim, lowercase, uppercase | string | Normalize the returned string |
| min, max | number, integer | Inclusive numeric range |
| multipleOf | number, integer | Require a numeric multiple |
| finite | number, integer | Reject NaN and infinities by default; set false to allow |
| items | array | Validate and transform every array item |
| minItems, maxItems | array | Limit array length |
| uniqueItems | array | Require structurally unique values |
| schema | object | Validate a nested object |
| unknownFields | object | Override unknown-field handling for one nested object |
Supported type values are string, number, integer, boolean, array, object, null, and any.
Nested arrays and objects
Rules can be nested to any reasonable depth:
const schema = {
users: {
type: "array",
required: true,
items: {
type: "object",
schema: {
id: { type: "integer", required: true },
email: { type: "string", required: true, format: "email" }
}
}
}
};An invalid email in the second item is reported as users[1].email.
Coercion
Coercion is disabled by default so validation stays predictable. Enable it globally or per field:
guardPayload(payload, schema, { coerce: true });
const schema = {
page: { type: "integer", coerce: true }
};Safe coercions include numeric and boolean strings such as "42", "true", and "false", plus numbers or booleans converted to strings. Empty strings are never converted to numbers.
Unknown fields and sanitization
Unknown fields are rejected by default:
guardPayload(payload, schema, { unknownFields: "reject" });Use strip to return only schema-defined fields, or preserve to keep additional fields:
const result = guardPayload(
{ name: "Ada", isAdmin: true },
{ name: { type: "string" } },
{ unknownFields: "strip" }
);
// result.data is { name: "Ada" }The keys __proto__, prototype, and constructor are rejected in every mode.
Custom validation and messages
const schema = {
password: {
type: "string",
minLength: 12,
validate: (value) =>
/[0-9]/.test(value) || "password must contain a number",
message: {
MIN_LENGTH: "Use at least 12 characters"
}
}
};Custom validators and transforms are synchronous. The callback context contains path, root, and parent.
Options
interface GuardOptions {
unknownFields?: "reject" | "strip" | "preserve";
abortEarly?: boolean;
coerce?: boolean;
maxDepth?: number;
maxErrors?: number;
errorMap?: (error: Readonly<ValidationError>) => string;
}unknownFieldsdefaults toreject.abortEarlydefaults tofalse.coercedefaults tofalse.maxDepthdefaults to32.maxErrorsdefaults to no limit.errorMapcan localize or rewrite messages while retaining the code and field.
API
guardPayload(payload, schema, options?)
Returns GuardResult<InferPayload<typeof schema>>. This is the best choice for HTTP handlers because expected validation failures do not throw.
defineSchema(schema)
An identity helper that preserves literal types for TypeScript inference.
import { defineSchema, guardPayload, type InferPayload } from "api-payload-guard";
const schema = defineSchema({
email: { type: "string", required: true },
role: { type: "string", enum: ["user", "admin"] as const }
});
type UserPayload = InferPayload<typeof schema>;
const result = guardPayload(input, schema);
if (result.valid) {
result.data.email; // string
result.data.role; // "user" | "admin" | undefined
}createPayloadGuard(schema, options?)
Creates a reusable guard for a route or event type. The schema configuration is checked once when the guard is created, and call-time options override the defaults. Treat the schema as immutable after creating the guard.
import { createPayloadGuard } from "api-payload-guard";
const validateUser = createPayloadGuard(userSchema, {
unknownFields: "strip"
});
const result = validateUser(req.body);assertPayload(payload, schema, options?)
Returns validated data or throws PayloadValidationError, which contains an errors array.
import {
assertPayload,
PayloadValidationError
} from "api-payload-guard";
try {
const data = assertPayload(input, schema);
} catch (error) {
if (error instanceof PayloadValidationError) {
console.error(error.errors);
}
}Express example
No framework adapter is required:
app.post("/users", async (req, res) => {
const result = guardPayload(req.body, createUserSchema, {
unknownFields: "strip"
});
if (!result.valid) {
return res.status(400).json({
error: "INVALID_REQUEST_BODY",
details: result.errors
});
}
const user = await User.create(result.data);
return res.status(201).json(user);
});The same pattern works with Fastify, Koa, Hono, Next.js route handlers, webhooks, queues, CLI input, and service boundaries.
Error codes
| Group | Codes |
| --- | --- |
| Payload | INVALID_PAYLOAD, REQUIRED_FIELD, INVALID_TYPE, UNKNOWN_FIELD, UNSAFE_KEY |
| Values | INVALID_ENUM, CUSTOM_VALIDATION, TRANSFORM_FAILED, DEFAULT_FAILED |
| Strings | MIN_LENGTH, MAX_LENGTH, PATTERN_MISMATCH, INVALID_FORMAT |
| Numbers | NOT_FINITE, MINIMUM, MAXIMUM, MULTIPLE_OF |
| Arrays | MIN_ITEMS, MAX_ITEMS, UNIQUE_ITEMS |
| Safety | MAX_DEPTH, CIRCULAR_REFERENCE |
Use code for application logic and message for display or logs. Payload values are deliberately omitted from errors to reduce accidental exposure of secrets.
Security scope
Payload validation is one layer of API security. Keep authorization, authentication, output encoding, rate limiting, database constraints, and secret handling in place. See SECURITY.md for vulnerability reporting.
Compatibility
- Node.js 18 or newer
- ESM (
import) packages - JavaScript and TypeScript
- No runtime dependencies
Contributing
Issues and pull requests are welcome. Read CONTRIBUTING.md before submitting changes.
