Skip to content

Latest commit

 

History

History
236 lines (173 loc) · 7.76 KB

5.validators.md

File metadata and controls

236 lines (173 loc) · 7.76 KB

Validators

Overview

Validators provide field-level data integrity checks that run automatically during create and update operations. The @Validator decorator attaches a custom validation function to a field, while @Mandatory ensures that specific fields are present in request bodies for given operations. The @Optional decorator explicitly marks a field as not mandatory.

The @Validator Decorator

Basic Usage

The @Validator decorator accepts a function that receives the field value and returns true if valid or false if invalid. It can instead return a parse result (see Parsed Values) so that the parsed value is written. The function can also return a Promise of either for asynchronous validation.

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:

  • Receives the raw field value from the request body: JSON, or a Date or undefined when a route wrapping new or edit amended the parsed body. FieldValue | undefined has to be narrowed before use.
  • Returns true when the value is valid: the raw value is written.
  • Returns { success: true, data } when the value is valid: data is written instead of the raw value.
  • Returns false or { success: false } when the value is invalid, which triggers a 400 Bad Request response.
  • Should handle unexpected types gracefully (e.g., check typeof before accessing properties).

Parsed Values

A validator returning a parse result writes the parsed value on both new and edit. The result has the shape of zod's safeParse, so a zod schema can be used directly:

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 can give early feedback, but a check followed by an insert is not a uniqueness guarantee: concurrent requests can both pass. Enforce uniqueness in the database where supported, and handle write conflicts. lookupByEmail below is an application-supplied function.

// 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

The @Mandatory decorator specifies which operations require a field in the request body. Unlike @Validator, which checks field content, @Mandatory only verifies that the field is provided:

  • On "edit", the body only carries the fields to update: a mandatory field absent from the body keeps its stored value and is accepted, while a mandatory field cleared by the body (null, "" or []) is rejected.
  • On any other operation (such as "new"), a mandatory field must be present in the body.
import { Mandatory } from "@antelopejs/interface-data-api/metadata";

Usage

Pass one or more operation names as arguments. Common operation names are "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

Use DefaultRoutes.WithOptions with the noMandatory option to create an endpoint that skips mandatory field checks.

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

The @Optional decorator explicitly marks a field as not mandatory for any operation. Use it on fields that have no other decorators but still need to be recognized by the Data API metadata system.

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

@Optional()
declare notes: string;

Error Handling

When validation or mandatory checks fail, the API returns a 400 Bad Request response whose JSON body names what went wrong and the fields concerned, for the client to word it and show it under each field:

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

The codes are exported as Validation.MISSING_MANDATORY_FIELDS and Validation.INVALID_FIELD_TYPES (@antelopejs/interface-data-api/components), the body's type as Validation.FieldsErrorBody. All fields are checked: fields lists every one that failed.

Next Steps

See the listable fields documentation to learn how to control field visibility in list operations and configure pagination.