Skip to content
Lucent
Esc
↑↓navigate↵open
On this page

lucent/schema module

Check and convert data with reusable schemas.

A schema describes the values your code expects. lucent/schema checks data against those rules and can clean it up or convert it. Define a schema once and reuse it whenever you need the same checks.

const s = require("lucent/schema");

const configSchema = s.object({
  mode: s.enum(["auto", "manual"]).default("auto"),
  retries: s.coerce.number().int().min(0),
  label: s.string().trim().min(1).optional(),
});

const config = configSchema.parse({ retries: "3" });
// { mode: "auto", retries: 3 }

This accepts either mode, fills in a missing mode, converts the retry count to a number, and allows the label to be omitted. Adding a rule creates a new schema, so you can build on an existing schema without changing it.

Check data

Method When to use it
.parse(value) Get the checked value, or throw ValidationError if it is invalid.
.safeParse(value) Handle valid and invalid data yourself.
.is(value) Ask whether a value passes the schema. Returns true or false.

These methods run synchronously. Call them directly, without yield* or await.

const result = configSchema.safeParse({ retries: -1 });

if (result.success) {
  console.log(result.data);
} else {
  console.log(s.formatIssues(result.issues));
  // $.retries: Use a number of 0 or more.
}

An issue tells you what failed through its message, path, and code. formatIssues turns issues into readable text. formatPath formats a single path.

Use parse or safeParse when you need converted values. For example, s.coerce.number().is("3") returns true, but the original value is still a string.

By default, parsing stops at the first problem. To collect more problems, pass { errors: "all" } as the second argument to parse or safeParse. You can also set maxIssues, maxDepth, or a message(issue) function to customize error text. The default limits are 100 issues per list and 256 levels of nesting.

Choose a value type

Schema What it accepts
s.string() Text, including an empty string.
s.number() Numbers, excluding NaN and infinity.
s.boolean() true or false.
s.date() A valid JavaScript Date.
s.literal("ready") Exactly the value you supply. Also works with numbers, booleans, null, and undefined.
s.enum(["small", "large"]) Any value in a nonempty list of strings or numbers.
s.null(), s.undefined() Exactly null or undefined.
s.unknown() Any value.
s.never() No value.

Add checks to narrow what a schema accepts:

const name = s.string().trim().min(1);
const count = s.number().int().min(0).max(100);
const tags = s.array(s.string()).max(10).unique();
Type Available checks
Strings .min(n), .max(n), .length(n), .regex(pattern), .startsWith(text), .endsWith(text), .includes(text)
Numbers .int(), .min(n), .max(n), .gt(n), .lt(n), .multipleOf(n)
Dates .min(date), .max(date), .gt(date), .lt(date)

min and max allow the boundary value. gt and lt require a value above or below it. int() requires a whole number within JavaScript’s safe integer range.

Most constructors and checks accept a custom message:

const name = s.string().min(1, { message: "Enter a name." });

Handle missing or invalid values

Fields are required by default, even when their schema is s.unknown().

Method What it does
.optional() Allows a missing field or undefined.
.optionalKey() Allows a missing field, but checks the value normally when present.
.nullable() Also allows null.
.nullish() Allows a missing field, undefined, or null.
.required() Requires a value and rejects undefined. On an object schema, makes its fields required.
.nonnullable() Rejects null.
.default(value) Uses a fallback for a missing field or undefined.
.catch(value) Uses a fallback when validation fails.
const retries = s.number().int().min(0).default(3);
retries.parse(undefined); // 3

const label = s.string().catch("Untitled");
label.parse(42); // "Untitled"

Fallbacks go through the schema’s checks and conversions too. An invalid fallback still fails. You can supply a function to create the fallback; a catch function receives { input, issues }.

Order matters. .optional().default("x") fills in "x" for undefined, while .default("x").optional() accepts undefined as it is.

Work with objects and lists

Use s.object(fields) for named fields, s.array(schema) for a list, and s.record(schema) for an object whose keys can vary.

const entry = s.object({
  name: s.string().min(1),
  enabled: s.boolean().default(true),
});

const entries = s.array(entry);
const entriesByName = s.record(entry);

Objects remove extra fields by default. Choose a different behavior with:

Method What happens to extra fields
.strip() Remove them. This is the default.
.strict() Report them as errors.
.passthrough() Keep them as they are.
.catchall(schema) Check and convert them with another schema.

To adapt an object schema, use .extend(fields), .pick(keys), or .omit(keys). Use .partial(keys?) or .required(keys?) to change which fields are required. Omitting the key list applies the change to every field. .keyof() makes an enum from the field names of a nonempty object schema.

Arrays support .min(n), .max(n), .length(n), and .unique(keySelector?). For example, .unique((entry) => entry.name) rejects duplicate names.

Records support .min(n), .max(n), and .size(n). To check keys too, use s.record(keySchema, valueSchema). Keys must become distinct strings. Only keys that are present are checked.

You can reuse parts through an object’s .shape, an array’s .element, a record’s .key and .value, or a tuple’s .items. Enum schemas expose .values.

Convert values

Normal schemas check the type you provide. Use s.coerce when you want to accept other types and convert them:

Schema What it converts
s.coerce.string() Strings, finite numbers, and booleans to text.
s.coerce.number() Numbers and decimal numeric strings to numbers. Empty strings fail.
s.coerce.boolean() Booleans, 0, 1, "0", "1", "false", and "true" to booleans.
s.coerce.date() Valid Dates, millisecond timestamps, and ISO date strings to Dates.

Date strings can be YYYY-MM-DD or timestamps with seconds and a timezone. Date-only strings use UTC. Invalid dates fail.

Strings also support .trim(), .toLowerCase(), .toUpperCase(), and .normalize(form?) to clean up text.

Use .transform(callback) for your own conversion, then .pipe(schema) to check the result:

const names = s
  .string()
  .transform((value) => value.split(","))
  .pipe(s.array(s.string().trim().min(1)));

names.parse("First, Second"); // ["First", "Second"]

Checks and conversions run in the order you write them. For example, .trim().min(1) trims first, then rejects empty text.

To change raw input before checking it, use .preprocess(callback). A transform or preprocess callback can report an issue or return s.INVALID to reject a value.

Add your own checks

Use .refine(predicate, options?) for a true-or-false check:

const even = s.number().refine((value) => value % 2 === 0, {
  message: "Use an even number.",
});

Use .check(callback) when you need to check related fields or report more than one problem:

const range = s
  .object({ min: s.number(), max: s.number() })
  .check((value, ctx) => {
    if (value.max < value.min) {
      ctx.addIssue({
        path: ["max"],
        message: "Set the maximum to the minimum or higher.",
      });
    }
  });

path points to the field with the problem. Leave it out to report a problem with the whole value. Custom issues can also include a rule name.

Callbacks must finish synchronously. Mistakes in your code still throw, including inside safeParse or catch. SchemaUsageError identifies invalid schema options or callback usage.

More schema tools

Tool When to use it
s.tuple([first, second]) Each position in a list needs a different schema. Add .rest(schema) for extra entries; optional positions must come last.
s.union([first, second]) A value can have several forms. Uses the first schema that passes.
s.discriminatedUnion(key, members) A field such as type selects the object schema. Each member needs a distinct required literal or enum value for that field.
s.lazy(() => schema) A structure contains more of the same structure, such as a tree.
s.instanceOf(Class) A value must be an instance of a class.
s.custom(predicate) You need to define a type check yourself.
s.json(schema) Read JSON text and check the parsed value.
s.jsonValue() Check that a value contains only data JSON can represent.
s.isSchema(value) Check whether a value is a Lucent schema.
.describe(text), .meta(data) Attach a description or extra information to a schema, available as .description and .metadata.
Type preview
Open page

Last updated on September 26, 2026

Was this page helpful?