Skip to content

Latest commit

 

History

History
245 lines (181 loc) · 8.39 KB

5.validators.md

File metadata and controls

245 lines (181 loc) · 8.39 KB
title Validators
description The @Validator, @Mandatory and @Optional decorators, the checks they run on create and update, and the 400 bodies they answer.
mode reference

Overview

Validators check field values on the new and edit routes. @Validator attaches a validation function to a field. @Mandatory requires a field in the request body for the named operations. @Optional marks a field as mandatory for no operation.

The routes parse the body, then run the mandatory check, then the validators. A failed mandatory check answers 400 before any validator runs.

The @Validator decorator

Basic usage

@Validator takes a function that receives the field value and returns true for a valid value or false for an invalid one. It can return a parse result instead (see parsed values), and the route then writes the parsed value. For asynchronous validation, the function returns a Promise of either.

import { Validator } from "@antelopejs/interface-data-api/metadata";

@RegisterDataController()
class UserAPI extends DataController(
  User,
  DefaultRoutes.All,
  Controller("/users"),
) {
  @ModelReference()
  @Model(UserModel, "my-database")
  declare userModel: UserModel;

  @Access(AccessMode.ReadWrite)
  @Validator((value) => typeof value === "string" && value.trim().length > 0)
  declare name: string;

  @Access(AccessMode.ReadWrite)
  @Validator((value) => typeof value === "number" && value >= 0 && value <= 120)
  declare age: number;
}

Validation function signature

type FieldValue =
  string | number | boolean | null | Date | FieldValue[] | FieldValues;

interface FieldValues {
  [field: string]: FieldValue | undefined;
}

type FieldValidator = (
  value: FieldValue | undefined,
) => FieldValidationResult | Promise<FieldValidationResult>;

type FieldValidationResult =
  | boolean
  | { success: true; data: FieldValue | undefined }
  | { success: false };

The function behaves as follows.

  • It runs only for a field present in the request body.
  • It receives the raw field value from the request body. The value is JSON, or a Date or undefined when a route that wraps new or edit amended the parsed body. Narrow FieldValue | undefined before you use it.
  • It returns true for a valid value, and the route writes the raw value.
  • It returns { success: true, data } for a valid value, and the route writes data instead of the raw value.
  • It returns false or { success: false } for an invalid value, and the route answers 400 Bad Request.
  • It receives any JSON type, so check the type with typeof before you read a property.

Parsed values

When a validator returns a parse result, the route writes the parsed value on both new and edit. The result has the shape of the result of zod's safeParse, so a zod schema works as a validator.

const startsAtSchema = z.coerce.date();

@Access(AccessMode.ReadWrite)
@Validator((value) => startsAtSchema.safeParseAsync(value))
declare startsAt: Date; // "2026-01-15" is stored as a Date

Common validation patterns

String validation

// Minimum length
@Validator((value) => typeof value === "string" && value.length >= 2)
declare firstName: string;

// Email format
const emailRegex = /^[\w](\.?[\w-]+)*@(?:[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}$/;
@Validator((value) => typeof value === "string" && emailRegex.test(value))
declare email: string;

// Enum-like constraint
const VALID_ROLES = ["admin", "editor", "viewer"];
@Validator((value) => typeof value === "string" && VALID_ROLES.includes(value))
declare role: string;

Number validation

// Range
@Validator((value) => typeof value === "number" && value >= 0 && value <= 120)
declare age: number;

// Integer only
@Validator((value) => typeof value === "number" && Number.isInteger(value))
declare quantity: number;

// Positive
@Validator((value) => typeof value === "number" && value > 0)
declare price: number;

Date validation

// Valid date string
@Validator((value) => typeof value === "string" && !isNaN(Date.parse(value)))
declare birthDate: string;

// Future date only
@Validator((value) =>
  typeof value === "string" &&
  !isNaN(Date.parse(value)) &&
  new Date(value) > new Date()
)
declare appointmentDate: string;

Async validation

An asynchronous lookup gives early feedback. A check followed by an insert does not guarantee uniqueness, because two concurrent requests can both pass the check. Enforce uniqueness in the database where the database supports it, and handle write conflicts. lookupByEmail below is a function your application supplies.

// Check uniqueness against the database
@Validator(async (value) => {
  if (typeof value !== "string") return false;
  const existing = await lookupByEmail(value);
  return !existing;
})
declare email: string;

The @Mandatory decorator

@Mandatory names the operations that require a field in the request body. @Validator checks the content of a field. @Mandatory checks only that the body provides it.

  • On "edit", the body carries only the fields to update. The route accepts a mandatory field absent from the body, which keeps its stored value. It rejects a mandatory field that the body clears with null, "" or [].
  • On any other operation, such as "new", the body must hold every mandatory field.
import { Mandatory } from "@antelopejs/interface-data-api/metadata";

Usage

Pass one or more operation names as arguments. The default routes check "new" (create) and "edit" (update).

@RegisterDataController()
class UserAPI extends DataController(
  User,
  DefaultRoutes.All,
  Controller("/users"),
) {
  @ModelReference()
  @Model(UserModel, "my-database")
  declare userModel: UserModel;

  @Access(AccessMode.ReadWrite)
  @Mandatory("new", "edit")
  declare email: string;

  @Access(AccessMode.WriteOnly)
  @Mandatory("new")
  declare password: string;

  @Access(AccessMode.ReadWrite)
  @Mandatory("edit")
  declare status: string;

  @Access(AccessMode.ReadWrite)
  declare notes: string; // Not mandatory for any operation
}

In this example:

  • email must be provided when creating a record and cannot be cleared when editing one.
  • password is required only when creating a new record.
  • status cannot be cleared when editing an existing record.
  • notes is always optional.

Skip mandatory validation

DefaultRoutes.WithOptions with the noMandatory option creates an endpoint that skips the mandatory field check. Any non-empty string turns the option on. The validators still run.

const routes = {
  edit: DefaultRoutes.Edit,
  patchEdit: DefaultRoutes.WithOptions(DefaultRoutes.Edit, {
    noMandatory: "true",
  }),
};

@RegisterDataController()
class UserAPI extends DataController(User, routes, Controller("/users")) {
  @Access(AccessMode.ReadWrite)
  @Mandatory("edit")
  declare status: string;
}

Requests to /users/edit cannot clear status, while requests to /users/patchEdit can.

The @Optional decorator

@Optional marks a field as mandatory for no operation, and records the field in the Data API metadata. Use it only on a field with no other decorator, since it clears what @Mandatory set.

import { Optional } from "@antelopejs/interface-data-api/metadata";

@Optional()
declare notes: string;

Error handling

When a mandatory check or a validator fails, the route answers 400 Bad Request. The JSON body holds a code for what went wrong and the fields concerned, so the client can word the error and show it under each field.

Failure Body
Mandatory { "code": "missing_mandatory_fields", "fields": ["email", "password"] }
Validator { "code": "invalid_field_types", "fields": ["age", "email"] }

@antelopejs/interface-data-api/components exports the codes as Validation.MISSING_MANDATORY_FIELDS and Validation.INVALID_FIELD_TYPES, and the body type as Validation.FieldsErrorBody. Each check covers every field, and fields lists every field that failed it.

Next steps

The listable fields page covers which fields list responses hold and how pagination works.