Give strings a clear meaning
"false" becomes false. number() treats an empty string as null for presence and defaults. Whitespace remains invalid. Accepted input formats are explicit, rather than relying on JavaScript truthiness or Number().
TYPE YOUR INPUT.
A small TypeScript schema library for CLI options, URL queries, and text forms.
ESM / TypeScript · No runtime dependencies · Standard Schema v1
import * as l from 'loculus';
const query = l.object({
q: l.string({ trim: true, min: 1 }),
page: l.number({
integer: true, gte: 1, default: 1,
}),
draft: l.boolean({ default: false }),
tag: l.array(l.enum(['docs', 'code']), {
wrapScalar: true, optional: true,
}),
});
const params = new URLSearchParams(location.search);
const value = l.parse(query, l.fromEntries(params));
01 / TRY IT
This runs the schema shown below.
Edit the input to explore conversions and errors.
import * as l from 'loculus';
const query = l.object({
q: l.string({ trim: true, min: 1 }),
page: l.number({
integer: true, gte: 1, default: 1,
}),
draft: l.boolean({ default: false }),
tag: l.array(l.enum(['docs', 'code']), {
wrapScalar: true, optional: true,
}),
});
The same schema handles every example. Objects are strict by default.
page becomes a number, draft becomes a boolean, and repeated tag values become an array.
02 / DESIGN
Many CLIs and forms work with names and strings. Loculus brings conversion, constraints, and missing-value handling together in one definition.
"false" becomes false. number() treats an empty string as null for presence and defaults. Whitespace remains invalid. Accepted input formats are explicit, rather than relying on JavaScript truthiness or Number().
Write string({ min: 1, optional: true }). No layers of optional, nullable, and default wrappers. Use gt / gte / lt / lte for exclusive and inclusive numeric bounds.
object() defaults to strict, catching misspelled options and unknown keys. Explicitly choose strip to discard extra keys, or passthrough to keep them.
For a CLI or form submission, schema construction is part of the work. Loculus optimizes construction plus one validation. Reuse the same schema when you need repeated validation.
03 / API ESSENTIALS
undefined, null, and empty strings are different inputs.
Choose which to accept and which to replace.
| Option | undefined | null |
|---|---|---|
optional: true | Preserved | Error |
nullable: true | Error | Preserved |
nullish: true | Preserved | Preserved |
default: 'guest' | 'guest' | Error |
default: { nullish: 'guest' } | 'guest' | 'guest' |
default: { null: 'anonymous' } | Error | 'anonymous' |
Each option is shown on its own with string(). Defaults do not replace empty strings or invalid values.
l.string({
default: { undefined: 'guest', null: 'anonymous' },
});Missing and null inputs can have different replacements. Defaults are treated as validated outputs and bypass constraints.
import { graphemes, utf8Bytes } from 'loculus/length';
l.config({ stringLength: graphemes });
l.string({ min: 1, max: 80 });
l.string({ length: utf8Bytes, max: 1024 });Available counters: codeUnits (default), codePoints, graphemes, displayWidth, utf8Bytes, and utf16Bytes. You can also supply a function. Application defaults apply at validation time to existing and new schemas; field counters take precedence. Count after trimming, before bounds. Reset with config({ stringLength: undefined }). Defaults are shared by all users of the same loaded module.
displayWidth sums terminal columns: full-width characters and emoji generally count as two, ambiguous characters as one, and control characters as zero. It ignores ANSI escapes, does not expand tabs, and does not measure pixels or the widest line. UTF-16 byte counts exclude a byte-order mark. Optional length helpers are separate from the core runtime.
l.string({ trim: true, min: 1, max: 80 });
l.number({ gt: 0, lte: 100 });
l.array(l.string());
l.array(l.string(), { wrapScalar: true });
const result = l.safeParse(query, input);
if (result.issues) {
console.log(result.issues);
} else {
console.log(result.value);
}
gt / lt exclude the bound; gte / lte include it. array() accepts only arrays by default. Set wrapScalar: true to wrap a single value in a one-element array.
safeParse returns { value } or { issues }. parse throws ValidationError on failure. Constraints stop at the first error within each field; errors from sibling fields are collected.
| API | Accepted values and key options |
|---|---|
string() | Strings only. trim / min / max / pattern. Length defaults to UTF-16 code units. Set length to a custom counter or use config({ stringLength }) for application defaults. |
number() | Decimal strings and finite numbers. Empty strings follow null presence/default settings. Rejects whitespace, hexadecimal notation, and Infinity. integer requires safe integers. |
boolean() | Booleans and the strings "true" / "false" / "1" / "0". Rejects numeric 0 / 1. |
enum() / literal() | enum uses string choices; literal supports fixed string, numeric, and boolean choices. |
date() | ISO date and date-time strings. Returns a Date; Date objects are not accepted as input. Supports local time, Z, and ±HH:MM. |
array() | Fields, row objects, or arrays as elements. min / max / wrapScalar. |
object() | Nested objects and optional / nullable / nullish parents. unknownKeys: strict / strip / passthrough. Object defaults require an explicit target. |
transform() / refine() | Synchronous conversion and extra validation. Exceptions from user callbacks propagate. |
union() / xor() | Accept any matching branch, or require exactly one matching branch. |
discriminatedUnion() / intersection() | Select by a discriminator, or merge both validation outputs. |
All field constructors support optional / nullable / nullish / default / description. Date-time strings without a time zone use the runtime's local time.
Infer Input<typeof query> and Output<typeof query>. For example, page accepts a string, number, or missing value on input and becomes a required number after validation.
04 / INTEGRATIONS
Implements Standard Schema v1.
Pass schemas directly to compatible tools.
Put a row schema inside an array for line items and product lists. Use it with useFieldArray; error paths such as items → 0 → quantity identify the row.
import * as l from 'loculus';
import { useForm } from 'react-hook-form';
import { standardSchemaResolver }
from '@hookform/resolvers/standard-schema';
const item = l.object({
name: l.string({ min: 1 }),
quantity: l.number({ integer: true, gte: 1 }),
});
const form = l.object({
title: l.string({ min: 1 }),
items: l.array(item, { wrapScalar: false, min: 1 }),
});
useForm<l.Input<typeof form>, unknown, l.Output<typeof form>>({
resolver: standardSchemaResolver(form),
});Browser tests cover adding, removing, and reordering rows, and displaying errors. Parsing dotted FormData field names requires a separate step.
The @politty/loculus adapter adds CLI metadata such as aliases, environment variables, and descriptions alongside value validation.
import * as l from 'loculus';
import { arg, defineCommand }
from '@politty/loculus';
const serve = defineCommand({
name: 'serve',
args: l.object({
port: arg(l.number({
integer: true, gte: 1,
lte: 65535, default: 3000,
}), { alias: 'p', env: 'PORT' }),
}),
run: ({ port }) => console.log(port),
});Adapter implementation See the adapter repository for its release status and installation instructions.
05 / ENGINEERING
Optimization preserves the required guarantees.
There is no claim to be fastest for every use case.
The ordinary entry point stands alone. ESM, sideEffects metadata, and tree-shaking checks help keep bundles small. Size depends on the APIs and build settings you use.
1,082 cases are checked on each of Node and Bun. Acceptance and successful outputs match at least one explicitly configured Zod or Valibot peer. This does not prove equivalence for every input or error representation.
View comparison code and results ↗@loculus/rolldown specializes static schemas while preserving the ordinary API. Unsupported definitions use ordinary processing. No runtime compile or eval is needed.
Single use includes schema construction plus one validation. Repeated-use measurements validate an existing schema. Benchmark CI includes schemas inspired by Git and Cloudflare cf commands, and forms with repeated rows.
Benchmark CI reports runtime latency, browser bundle sizes, and TypeScript inference costs separately. Results apply to the measured schemas and environments; they do not establish overall superiority.
Generated code may be larger or slower, depending on the engine and schema. The plugin does not speed up TypeScript type checking.
import { defineConfig } from 'tsdown';
import { loculusPlugin } from '@loculus/rolldown';
export default defineConfig({
entry: ['src/main.ts'],
plugins: [loculusPlugin()],
});Supports Rolldown >=1.2.13 and <2. Optimizes static string / number / boolean schemas and some array / object schemas. Dynamic options use ordinary processing.
Removing unused construction calls depends on build settings. Tested Rolldown minification removes them; esbuild requires @__PURE__ annotations at call sites.
06 / SCOPE & LIMITS
Specializing in input boundaries has limits.
Loculus is not a full application data-modeling library.
CLI options, search queries, and text-focused forms. Define conversions and missing-value handling together in a short schema.
Use array(object) for repeated form rows. The library focuses on flat inputs with selected support for nesting.
For recursive schemas, Map, Set, tuples, specialized formats, or asynchronous validation, explore Zod and Valibot.
Loculus has its own error representation. Standard Schema support does not make it a direct replacement for code that expects Zod error codes or union error structures.
Use object({ address: object(...) }) or array(array(...)). Parent optionality and child optionality are independent. Each array level requires an array by default; explicitly set wrapScalar on every level that should wrap scalars.
Defaults are treated as validated outputs. In string({ min: 5, default: 'x' }), a missing value returns 'x' without a length error. Use defaults that satisfy your output contract. Referenced defaults keep their identity.
Declared fields read ordinary JavaScript properties, including inherited values. Treat schemas and introspection metadata as immutable after creation. Construct a new schema when a definition changes. Declared __proto__ fields are skipped, and passthrough excludes that key.
Distributed packages contain JavaScript compiled from TypeScript. The benchmark workflow measures selected cases on Node and Bun. Compare results for your own schemas and environment before drawing performance conclusions.
Start with the query example. Express conversions and rejection rules in your schema.