loculus0.4.1 GitHub

TYPE YOUR INPUT.

String inputs.
Useful values.

A small TypeScript schema library for CLI options, URL queries, and text forms.

ESM / TypeScript · No runtime dependencies · Standard Schema v1

query.ts
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));
CLI / QUERY / FORM
VALIDATE + CONVERT
APPLICATION VALUES

01 / TRY IT

What will your input become?

This runs the schema shown below.
Edit the input to explore conversions and errors.

Schema
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.

Input arrives as strings

page becomes a number, draft becomes a boolean, and repeated tag values become an array.

View the input object
Validation resultLoading
Validated by the Loculus JavaScript runtime.

02 / DESIGN

An API shaped
by your inputs.

Many CLIs and forms work with names and strings. Loculus brings conversion, constraints, and missing-value handling together in one definition.

01

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().

02

Constraints and presence as options

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.

03

Reject unexpected input by default

object() defaults to strict, catching misspelled options and unknown keys. Explicitly choose strip to discard extra keys, or passthrough to keep them.

04

Count the cost of using it once

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

Make edge cases explicit.

undefined, null, and empty strings are different inputs.
Choose which to accept and which to replace.

Missing values, null, and defaults

Optionundefinednull
optional: truePreservedError
nullable: trueErrorPreserved
nullish: truePreservedPreserved
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.

Choose how string length is counted

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.

Bounds, repeated values, and errors

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.

Basic types and conversion rules
APIAccepted 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.

INPUT ≠ OUTPUT

Types before and after conversion.

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

For forms. For CLIs.

Implements Standard Schema v1.
Pass schemas directly to compatible tools.

REACT HOOK FORM

Repeated rows with array(object).

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.

form.ts
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),
});
Try the working form

Browser tests cover adding, removing, and reordering rows, and displaying errors. Parsing dotted FormData field names requires a separate step.

POLITTY

The same schemas for CLI arguments.

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

Small and fast.
Claims backed by measurements.

Optimization preserves the required guarantees.
There is no claim to be fastest for every use case.

0

Runtime dependencies

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.

96 profiles

Compatibility profiles

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 ↗
build-time

Optional build-time optimization

@loculus/rolldown specializes static schemas while preserving the ordinary API. Unsupported definitions use ordinary processing. No runtime compile or eval is needed.

Measurement scope and plugin usage

Measure single use and reuse separately

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

Before you use it.

Specializing in input boundaries has limits.
Loculus is not a full application data-modeling library.

A GOOD FIT

Inputs expressed as names and values

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.

ALSO CONSIDER OTHER LIBRARIES

Broader types and asynchronous work

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.

Nested form values are supported; recursive schemas are not

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 bypass constraints and transformations

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.

Inherited properties and schema immutability

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.

Runtime and measurement scope

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.

Input rules, in one place.

Start with the query example. Express conversions and rejection rules in your schema.

Try the example ↑