Skip to content
 
 

Repository files navigation

Stringent

npm CI License: MIT

A type-safe expression parser and evaluator for TypeScript. One grammar definition drives two engines: a type-level parser (expressions in string literals are validated and fully typed at compile time) and a runtime parser (dynamic strings get structured errors and evaluation).

const result = parser.evaluate(
  "values.password == values.confirmPassword",
  { values: { password: "string", confirmPassword: "string" } }, // schema
  { values: { password: "hunter2", confirmPassword: "hunter2" } } // values
);
//    ^? boolean (= true)

📚 Documentation: guides, a live playground, and the API reference live at eralmansouri.github.io/stringent. The code examples there are compiled against the real library on every docs build — hover them to see the actual inferred types.

Note Pre-1.0: the API is stabilizing but may still change between releases. See DESIGN.md for the architecture and its rationale.

Why stringent

  • Compile-time validationparse() only accepts string literals that fully parse against your grammar. Syntax errors, operand type mismatches, and even typos in schema leaves are compile errors, and valid expressions get an exactly inferred AST and result type.
  • Real type expressions — constraints, result types, and schemas are arktype definitions ("string | number", "number > 0", "string.email"), and constraint matching is assignability, not name equality. Binding names are automatically in scope: rest("left") means "assignable to whatever left parsed as".
  • Structured runtime errorssafeParse() never throws. You get an error code (PARSE_ERROR / TYPE_MISMATCH / UNEXPECTED_INPUT / INVALID_SCHEMA), a 0-based position, and the tokens that would have been valid there.
  • Rules as Standard Schemasparser.compile() turns a rule into a real arktype Type: cross-field predicate rules validate a values object with field-attributed ArkErrors, so a rule drops straight into react-hook-form, tRPC, or hono.
  • Secure by default — expressions are untrusted input. All identifier and path lookups are own-property only; __proto__ and constructor never resolve to prototype internals. Failure messages never include runtime values.

Installation

npm install stringent   # or: pnpm add stringent

ESM-only. Depends on arktype (the type engine) and parsebox (tokenizers).

Quickstart

Each defineNode call declares one grammar rule, authored as a fluent builder chain: elements left to right, .as(name) naming bindings, .result(def) declaring the result type, .eval(fn) attaching evaluation. Every arktype def is validated where it is written — a typo like operand("nmbr") is a compile error at that call. Two element roles place subexpressions: operand() parses at the next tighter precedence level, rest() parses at the current level — and the tail element's role is what makes a level left- or right-associative (an operand() tail folds left; a rest() tail recurses right).

import { defineNode, createParser } from "stringent";

// Leaf nodes live at the HIGHEST precedence level.
// Single-element passthrough patterns take no resultType.
const numberLit = defineNode({
  name: "num",
  precedence: 4,
  pattern: (p) => p.number(),
});
const variable  = defineNode({
  name: "var",
  precedence: 4,
  pattern: (p) => p.path(),
});

// ONE overloaded add: number+number → number, string+string → string.
// "number | string" is an arktype def; "left" is a binding reference.
// The operand() tail makes the level LEFT-associative: 1+2+3 = (1+2)+3.
const add = defineNode({
  name: "add",
  precedence: 2,
  pattern: (p) =>
    p
      .operand("number | string").as("left")
      .constVal("+")
      .operand("left").as("right")
      .result("left")
      .eval((b) => {
        const l = b.left(); // bindings arrive as memoized thunks
        const r = b.right();
        return typeof l === "string" ? `${l}${String(r)}` : Number(l) + Number(r);
      }),
});

// A rest() tail makes a level RIGHT-associative: 2^3^2 = 2^(3^2)
const pow = defineNode({
  name: "pow",
  precedence: 3,
  pattern: (p) =>
    p
      .operand("number").as("left")
      .constVal("^")
      .rest("number").as("right")
      .result("number")
      .eval(({ left, right }) => left() ** right()),
});

const parser = createParser([numberLit, variable, add, pow] as const);

String literals are checked by the type engine — invalid expressions don't compile, and result types are inferred through the grammar:

const [ast] = parser.parse("1+2^3", {});  // AST type fully inferred
parser.parse("1+", {});                   // ✗ compile error
parser.parse("1+'a'", {});                // ✗ compile error: 'a' ⊄ number | string… and 'a' ⊄ left

parser.evaluate("1+2^3", {}, {});                   // 9, typed number
parser.evaluate("x+1", { x: "number" }, { x: 41 }); // 42, typed number

Dynamic strings go through safeParse(), which returns structured errors instead of throwing:

const result = parser.safeParse(userInput, { x: "number" });
if (result.success) {
  parser.evaluateAst(result.ast, { x: 21 });
} else {
  result.error.message;  // "Expected a number expression at position 2, got string"
  result.error.position; // 0-based offset into the input
  result.error.expected; // tokens that would have been valid there
}

Rules become arktype Types (and therefore Standard Schemas) with compile() — a boolean rule validates values and attributes failures to a field; any other rule maps values to its result:

const rule = parser.compile(
  "values.password == values.confirmPassword",
  { values: { password: "string", confirmPassword: "string" } },
  { path: ["values", "confirmPassword"], message: "passwords to match" }
);
rule({ values: { password: "a", confirmPassword: "a" } }); // → the values object
rule({ values: { password: "a", confirmPassword: "b" } }); // → ArkErrors, flatByPath
// rule is a Standard Schema — pass it to react-hook-form, tRPC, hono, …

There's more — quoted strings with escapes, keyword literals as plain const nodes, parentheses, short-circuiting ternaries (evaluation is uniformly lazy), polymorphic evals via arktype match, nested schemas with dotted-path member access (values.password), and compile-time + construction-time grammar validation:

Development

pnpm install
pnpm typecheck   # includes type-level tests (src/**/*.typetest.ts)
pnpm test        # vitest runtime tests
pnpm bench       # vitest bench — parse/evaluate/compile benchmarks
pnpm build
pnpm check:package  # publint + arethetypeswrong

The type-level and runtime engines are hand-mirrored (src/parse/index.tssrc/runtime/parser.ts); the parity assertions in src/parser.test.ts and src/types.typetest.ts pin both to the same behavior — extend both when adding grammar features. See DESIGN.md for the full architecture and V2-PLAN.md for the v1→v2 migration table.

License

MIT

About

A type-safe expression parser for TypeScript with compile-time validation and inference

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages