miqro · @miqro/core · @miqro/parser · @miqro/query · @miqro/jsx · @miqro/jsx-dom · @miqro/jsx-node · @miqro/request · @miqro/runner · @miqro/test · @miqro/test-http

@miqro/parser

Schema validation and coercion with full TypeScript inference.

basic usage

import { Parser } from "@miqro/parser";

const parser = new Parser();

// Type is inferred automatically from the schema
const result = parser.parse({ id: "1", title: "hello" }, {
  type: "object",
  properties: {
    id: "integer",
    title: "string",
    published: "boolean?"
  }
});

result.id        // number  (coerced from string)
result.title     // string
result.published // boolean | undefined

To declare a schema separately and keep inference, use as const satisfies Schema:

import { Parser, Schema } from "@miqro/parser";

const PostSchema = {
  type: "object",
  properties: { id: "integer", title: "string", published: "boolean?" }
} as const satisfies Schema;

const post = parser.parse(raw, PostSchema);
post.id        // number
post.published // boolean | undefined

as const keeps the literal types the inference reads, and satisfies Schema checks the schema is well formed without widening it. The const modifier on parse only applies to schemas written inline at the call site, which is why a schema stored in a variable needs the as const itself.

Two spellings that look equivalent and are not:

const PostSchema: Schema<Post> = { ... };   // ✗ unsound
const post = parser.parse(raw, PostSchema); // typed Post — even if the schema
                                            // describes entirely different properties

Annotating with Schema<Post> makes inference short-circuit to Post without checking that the schema produces one. post.id then compiles cleanly and is undefined at runtime.

const PostSchema = { ... };                 // ✗ inference degrades
const PostSchema = { ... } satisfies Schema; // ✗ inference degrades
const post = parser.parse(raw, PostSchema); // typed unknown

Without as const the property strings widen to string, and there is nothing left to infer from.

type string syntax

"string"         required string
"string?"        optional string (undefined if absent)
"number"         required number
"integer"        required integer (coerced)
"boolean"        required boolean ("true"/"false" coerced)
"string[]"       array of strings
"string[]?"      optional array of strings
"string[]!"      force single value into array, always returns string[]
"string[]!?"     optional force-array
"string|number"  string or number (first match wins)
"string|number?" string or optional number

built-in types

string, string1 (non-empty), number, integer, boolean, date
url, email, regex, regexp, function, any
object, array, dict, enum
decodeHTML, encodeHTML, schema

date

Returns a Date. Accepts a Date, epoch milliseconds, or a string in the ECMAScript Date Time String Format — and nothing else.

parser.parse("2026-07-30", "date");                 // 2026-07-30T00:00:00.000Z
parser.parse("2026-07-30T12:00:00.000Z", "date");   // that instant
parser.parse(1785412800000, "date");                // from epoch ms

It rejects what new Date(...) quietly accepts: null (the epoch), true (1 ms after it), "", unparseable strings (an Invalid Date object rather than an error), and a day that does not exist. Date range checks the month but not the day, so both of these roll over to March 2 through it and both throw here:

parser.parse("2026-02-30", "date");                 // throws
parser.parse("2026-02-30T00:00:00.000Z", "date");   // throws

A date-only string is UTC midnight, built from its own components, so one calendar day is one instant on every host:

parser.parse("2026-07-30", "date");   // 2026-07-30T00:00:00.000Z in every timezone
parser.parse("2026-07", "date");      // 2026-07-01T00:00:00.000Z
parser.parse("2026", "date");         // 2026-01-01T00:00:00.000Z

Spellings outside the format are refused rather than read in the host's timezone. All three of these parse in V8, all three as local time — "2026/07/30" in Asia/Tokyo is the day before:

parser.parse("2026/07/30", "date");            // throws
parser.parse("Jul 30 2026", "date");           // throws
parser.parse("2026-07-30 12:00:00", "date");   // throws — note the space

A date-time string carries whatever zone it states (Z, +02:00, -0200). With no zone it is local time, which is what the format says it means:

parser.parse("2026-07-30T12:00:00Z", "date");   // that instant everywhere
parser.parse("2026-07-30T12:00:00", "date");    // local — depends on the host

url

url accepts any protocol new URL() accepts, including javascript:, data: and file:. When the value becomes a redirect target or a fetch destination, say which protocols are allowed:

parser.parse(raw, { type: "url", urlProtocols: ["http:", "https:"] });

It returns a URL instance at runtime, but infers as unknown: TypeMap cannot name URL without the dom lib, so it declares url: unknown, and that declaration cannot be augmented away — TypeScript answers TS2717: Property 'url' must be of type 'unknown'. Narrow at the call site instead:

const UrlSchema: Schema<URL> = { type: "url", urlProtocols: ["http:", "https:"] };
parser.parse(raw, UrlSchema);      // URL

parser.parse(raw, "url") as URL;   // or cast

object schema

const result = parser.parse(raw, {
  type: "object",
  properties: {
    name: "string",
    age: "integer?",
    address: {
      type: "object?",
      properties: {
        city: "string",
        zip: "string?"
      }
    },
    tags: {
      type: "array",
      arrayType: "string"
    }
  }
});

result.name           // string
result.age            // number | undefined
result.address?.city  // string
result.tags           // string[]

mode

mode: "no_extra"     (default) throws if unknown properties are present
mode: "remove_extra" strips unknown properties, returns defined shape only
mode: "add_extra"    keeps unknown properties, type is defined shape & Record<string, unknown>
const result = parser.parse(raw, {
  type: "object",
  mode: "add_extra",
  properties: { id: "integer" }
});

result.id          // number
result["anything"] // unknown

array

// array of primitives
parser.parse(raw, { type: "array", arrayType: "string" }); // string[]

// array of objects — define element shape with co-located properties
const result = parser.parse(raw, {
  type: "array",
  arrayType: "object",
  properties: {
    id: "integer",
    label: "string"
  }
});
result[0].id    // number
result[0].label // string

// array of arrays
parser.parse(raw, { type: "array", arrayType: "number[]" }); // number[][]

enum

const result = parser.parse(raw, {
  type: "enum",
  enumValues: ["a", "b", "c"]
});
// result → "a" | "b" | "c"

dict

// dict of primitives
parser.parse(raw, { type: "dict", dictType: "number" });
// → Record<string, number>

// dict of objects — define value shape with co-located properties
const result = parser.parse(raw, {
  type: "dict",
  dictType: "object",
  properties: { x: "string", y: "number?" }
});
result["key"].x // string
result["key"].y // number | undefined

Aliases: Dict<string> and dict<string> are equivalent as type strings, as are Array<string> and string[] (and Array<string>! and string[]!).

A parsed dict is created with Object.create(null), so it has no prototype — spread, Object.keys and JSON.stringify work, result.hasOwnProperty(...) does not. That is deliberate: a "__proto__" key on a plain object literal goes through the inherited accessor and replaces the result's prototype instead of creating a property.

allowNull

parser.parse(raw, { type: "string", allowNull: true }); // string | null
parser.parse(raw, { type: "enum", enumValues: ["a", "b"], allowNull: true }); // "a" | "b" | null

schema options

Beyond type and properties, a schema object takes:

required           false makes the value optional — same as a "?" suffix
defaultValue       returned when the value is undefined (and makes the key non-optional)
parseJSON          JSON.parse a string value first; throws if the value is not a string
forceArray         wrap a non-array value in an array — same as a "!" suffix
allowNull          accept null and return it unchanged
mode               object only — extra-property handling, see above
name               plumbing only — always overwritten by parse()'s third argument
                   (and by the derived path when nested), so writing it does nothing
usage              replaces the error message on any failure, missing values included
description        free text, carried through for doc generation
title              free text, carried through for doc generation

regex              string | RegExp — required by "regex", and honoured by "string" too
stringMinLength    string, string1, email, url
stringMaxLength
numberMin          number, integer
numberMax
numberMinDecimals
numberMaxDecimals
arrayMinLength     array
arrayMaxLength
arrayType          array element type
dictType           dict value type
enumValues         enum
urlProtocols       url
parser.parse(undefined, { type: "string", defaultValue: "anon" });   // "anon"
parser.parse('{"a":1}', { type: "object", parseJSON: true,
                          properties: { a: "integer" } });           // { a: 1 }
parser.parse("AB1", { type: "string", regex: "^[a-z]+$",
                      usage: "must be lowercase letters" });         // throws that message

InferSchema utility type

Use InferSchema<S> to derive the TypeScript type from a schema definition without calling parse:

import { InferSchema } from "@miqro/parser";

type T1 = InferSchema<"string?">;                                    // string | undefined
type T2 = InferSchema<"number[]">;                                   // number[]
type T3 = InferSchema<{ type: "object"; properties: { x: "string" } }>; // { x: string }
type T4 = InferSchema<{ type: "enum"; enumValues: readonly ["a","b"] }>; // "a" | "b"
type T5 = InferSchema<{ type: "number"; allowNull: true }>;          // number | null

custom parser

import { Parser } from "@miqro/parser";

const parser = new Parser();
parser.register("uuid", (value) => {
  if (typeof value === "string" && /^[0-9a-f-]{36}$/.test(value)) {
    return value;
  }
  // return undefined or throw ParseOptionsError to fail validation
});

parser.parse("123e4567-...", "uuid"); // → unknown

register also accepts a Schema or another type name in place of the function, and takes aliases:

parser.register("posId", { type: "integer", numberMin: 1 });   // schema
parser.register("token", "string1", ["Token"]);                // alias + aliases

Registering a name twice throws, and a name containing | or ? is rejected. Every registered type T automatically gains T[], T[]! and dict<T> (suppress with the noList / noDict parameters).

To get type inference for custom parsers, augment TypeMap in the module where it is declared — note the .js extension:

declare module "@miqro/parser/build/built-in-parsers.js" {
  interface TypeMap {
    uuid: string;
  }
}

parser.parse("123e4567-...", "uuid");  // → string
parser.parse(raw, "uuid[]");           // → string[]
parser.parse(raw, "dict<uuid>");       // → Record<string, string>

The extension is required under moduleResolution: "nodenext"; without it TypeScript reports TS2664: Invalid module name in augmentation and the types stay unknown. Augmenting "@miqro/parser" instead reports nothing at all and also has no effect — TypeMap is declared in built-in-parsers, and that is the module the augmentation has to name.

errors

Every failure throws ParseOptionsError. argAttr is the dotted path to the property that failed, built from the third argument to parse:

import { ParseOptionsError } from "@miqro/parser";

try {
  parser.parse({ a: { b: "x" } }, {
    type: "object",
    properties: { a: { type: "object", properties: { b: "integer" } } }
  }, "body");
} catch (e) {
  if (e instanceof ParseOptionsError) {
    e.name     // "ParseOptionsError"
    e.message  // "body.a.b not number min decimals[0] max decimals[0]"
    e.argAttr  // "body.a.b"
  }
}

Omit the third argument and the paths start at the property name (a.b).

set / get

Path helpers, exported alongside Parser. Both reject __proto__, __prototype__, constructor and prototype anywhere in the path.

import { set, get } from "@miqro/parser";

set({}, "a.b.c", 1);                    // { a: { b: { c: 1 } } } — creates intermediates
get({ a: { b: { c: 1 } } }, "a.b.c");   // 1
get({}, "x.y", "fallback");             // "fallback"
set({}, "__proto__.x", 1);              // throws Error: invalid attrPath

An array path (["a", "b", "c"]) works the same as a dotted string.