lingo docs
Make forms easier, LLM tools safer. People type 180cm or 5ft 11; models emit "1½ cups" or "twenty-five kg"; your database wants canonical values: one number in one unit, one ISO date. The parser turns both into validated data and humanizes it back.
On the web, one text field can replace a value box plus a unit dropdown. It stays quiet mid-typing and puts a stable code, original-input span, and did-you-mean candidate on every issue. At the tool boundary, the same parser validates what models emit: ambiguity fails with an actionable candidate instead of a silent guess.
Zero runtime dependencies. Entries are tree-shakeable. Pass an explicit now for reproducible date parsing. Start with Parse, then Forms for user input or Tool fields for LLM boundaries.
Install the package, then import from the entry that matches your runtime. Every entry is tree-shakeable.
pnpm add @pascal-app/lingonpm install @pascal-app/lingoyarn add @pascal-app/lingobun add @pascal-app/lingoZero runtime dependencies, 33 unit kinds, and an original-input span on every result.
Turn any text into a canonical, validated value, then read it back. lingo() returns a discriminated union; shaped helpers fix the contract.
Parse readout
Warnings can succeed; only errors block the value.
{
"schemaVersion": 3,
"ok": true,
"type": "conversion",
"kind": "length",
"source": {
"type": "quantity",
"value": 72,
"unit": "in",
"base": 1.8288,
"baseUnit": "m"
},
"converted": {
"value": 182.88,
"unit": "cm"
},
"text": "72 in to cm",
"span": {
"start": 0,
"end": 11,
"text": "72 in to cm"
},
"issues": [],
"confidence": 1
}System picks the gallon family; numberFormat resolves separator ambiguity.
AMBIGUOUS_NUMBER: "1,234" could mean 1234 or 1.234 — assuming 1234.
AMBIGUOUS_NUMBER: "1,234" could mean 1234 or 1.234 — assuming 1234.
{
"system:\"us\"": {
"schemaVersion": 3,
"ok": true,
"type": "quantity",
"kind": "volume",
"value": 1234,
"unit": "gal",
"base": 4.671198141456,
"baseUnit": "m3",
"text": "1,234 gallons",
"span": {
"start": 0,
"end": 13,
"text": "1,234 gallons"
},
"issues": [
{
"code": "AMBIGUOUS_NUMBER",
"severity": "warning",
"message": "\"1,234\" could mean 1234 or 1.234 — assuming 1234.",
"span": {
"start": 0,
"end": 5,
"text": "1,234"
},
"data": {
"text": "1,234",
"a": "1234",
"b": "1.234"
}
}
],
"confidence": 0.8,
"alternatives": [
{
"type": "quantity",
"reason": "AMBIGUOUS_NUMBER",
"confidence": 0.4,
"quantity": {
"schemaVersion": 3,
"type": "quantity",
"kind": "volume",
"value": 1.234,
"unit": "gal",
"base": 0.004671198141456,
"baseUnit": "m3"
}
}
]
},
"system:\"imperial\"": {
"schemaVersion": 3,
"ok": true,
"type": "quantity",
"kind": "volume",
"value": 1234,
"unit": "gal-imp",
"base": 5.60987506,
"baseUnit": "m3",
"text": "1,234 gallons",
"span": {
"start": 0,
"end": 13,
"text": "1,234 gallons"
},
"issues": [
{
"code": "AMBIGUOUS_NUMBER",
"severity": "warning",
"message": "\"1,234\" could mean 1234 or 1.234 — assuming 1234.",
"span": {
"start": 0,
"end": 5,
"text": "1,234"
},
"data": {
"text": "1,234",
"a": "1234",
"b": "1.234"
}
}
],
"confidence": 0.8,
"alternatives": [
{
"type": "quantity",
"reason": "AMBIGUOUS_NUMBER",
"confidence": 0.4,
"quantity": {
"schemaVersion": 3,
"type": "quantity",
"kind": "volume",
"value": 1.234,
"unit": "gal-imp",
"base": 0.00560987506,
"baseUnit": "m3"
}
}
]
},
"numberFormat:\"comma-decimal\"": {
"schemaVersion": 3,
"ok": true,
"type": "quantity",
"kind": "volume",
"value": 1.234,
"unit": "gal",
"base": 0.004671198141456,
"baseUnit": "m3",
"text": "1,234 gallons",
"span": {
"start": 0,
"end": 13,
"text": "1,234 gallons"
},
"issues": [],
"confidence": 1
},
"numberFormat:\"dot-decimal\"": {
"schemaVersion": 3,
"ok": true,
"type": "quantity",
"kind": "volume",
"value": 1234,
"unit": "gal",
"base": 4.671198141456,
"baseUnit": "m3",
"text": "1,234 gallons",
"span": {
"start": 0,
"end": 13,
"text": "1,234 gallons"
},
"issues": [],
"confidence": 1
}
}Find values in text
findQuantities scans free text and returns every quantity, range, and conversion it finds, each with a span pointing at the exact characters in the original string.
import { findQuantities } from "@pascal-app/lingo"
const found = findQuantities("ship 2 boxes at 5 kg each by friday")
// [{ result, span: { start, end } }, ...] — offsets into the original text
for (const { result, span } of found) {
if (result.type === "quantity") highlight(span, result.quantity)
}One strictness dial — forgiving, confirm, strict — sets field personality. Accept switches, tolerance, and escalate tune the pieces without changing the grammar.
import { lingo } from "@pascal-app/lingo"
const result = lingo("5 meterz", {
kind: "length",
strictness: "confirm",
})
if (!result.ok && result.candidate?.type === "quantity") {
result.candidate.quantity.format()
}Escalated issues keep their code; only severity moves.
One text runs through forgiving, confirm, and strict modes.
The field is length-biased and assumes centimeters for bare numbers.
TYPO_CORRECTED: That looks like a unit typo. Confirm before saving.
TYPO_CORRECTED: That looks like a unit typo. Confirm before saving.
UNKNOWN_UNIT: Try cm, m, ft, in, or kg.
{
"forgiving": {
"schemaVersion": 3,
"ok": true,
"type": "quantity",
"kind": "length",
"value": 5,
"unit": "m",
"base": 5,
"baseUnit": "m",
"text": "5 meterz",
"span": {
"start": 0,
"end": 8,
"text": "5 meterz"
},
"issues": [
{
"code": "TYPO_CORRECTED",
"severity": "warning",
"message": "That looks like a unit typo. Confirm before saving.",
"span": {
"start": 2,
"end": 8,
"text": "meterz"
},
"data": {
"unit": "meterz",
"corrected": "m"
}
}
],
"confidence": 0.85
},
"confirm": {
"schemaVersion": 3,
"ok": false,
"type": "failure",
"text": "5 meterz",
"issues": [
{
"code": "TYPO_CORRECTED",
"severity": "error",
"message": "That looks like a unit typo. Confirm before saving.",
"span": {
"start": 2,
"end": 8,
"text": "meterz"
},
"data": {
"unit": "meterz",
"corrected": "m"
}
}
],
"candidate": {
"schemaVersion": 3,
"ok": true,
"type": "quantity",
"kind": "length",
"value": 5,
"unit": "m",
"base": 5,
"baseUnit": "m",
"text": "5 meterz",
"span": {
"start": 0,
"end": 8,
"text": "5 meterz"
},
"issues": [
{
"code": "TYPO_CORRECTED",
"severity": "warning",
"message": "That looks like a unit typo. Confirm before saving.",
"span": {
"start": 2,
"end": 8,
"text": "meterz"
},
"data": {
"unit": "meterz",
"corrected": "m"
}
}
],
"confidence": 0.85
}
},
"strict": {
"schemaVersion": 3,
"ok": false,
"type": "failure",
"text": "5 meterz",
"issues": [
{
"code": "UNKNOWN_UNIT",
"severity": "error",
"message": "Try cm, m, ft, in, or kg.",
"span": {
"start": 2,
"end": 8,
"text": "meterz"
},
"data": {
"unit": "meterz"
}
}
]
}
}Switches reject shapes while preserving the candidate parse.
Reject parse shapes without discarding candidates.
Messages pass into every parser call.
{
"accept": {
"ranges": true,
"conversions": true,
"compounds": true,
"fuzzy": true,
"numberWords": true,
"approximations": true,
"bareNumbers": true
},
"tolerance": {
"typos": "fix"
},
"messages": {
"TYPO_CORRECTED": "That looks like a unit typo. Confirm before saving.",
"UNIT_ASSUMED": "Choose an explicit unit before this value is stored.",
"UNKNOWN_UNIT": "Try cm, m, ft, in, or kg.",
"SINGLE_VALUE_EXPECTED": "Use one value here; ranges belong in the range field."
}
}Strictness changes issue severity, not grammar.
TYPO_CORRECTED: Read "meterz" as m.
TYPO_CORRECTED: Read "meterz" as m.
UNKNOWN_UNIT: Unknown unit "meterz"
{
"strictness:\"forgiving\"": {
"schemaVersion": 3,
"ok": true,
"type": "quantity",
"kind": "length",
"value": 5,
"unit": "m",
"base": 5,
"baseUnit": "m",
"text": "5 meterz",
"span": {
"start": 0,
"end": 8,
"text": "5 meterz"
},
"issues": [
{
"code": "TYPO_CORRECTED",
"severity": "warning",
"message": "Read \"meterz\" as m.",
"span": {
"start": 2,
"end": 8,
"text": "meterz"
},
"data": {
"unit": "meterz",
"corrected": "m"
}
}
],
"confidence": 0.85
},
"strictness:\"confirm\"": {
"schemaVersion": 3,
"ok": false,
"type": "failure",
"text": "5 meterz",
"issues": [
{
"code": "TYPO_CORRECTED",
"severity": "error",
"message": "Read \"meterz\" as m.",
"span": {
"start": 2,
"end": 8,
"text": "meterz"
},
"data": {
"unit": "meterz",
"corrected": "m"
}
}
],
"candidate": {
"schemaVersion": 3,
"ok": true,
"type": "quantity",
"kind": "length",
"value": 5,
"unit": "m",
"base": 5,
"baseUnit": "m",
"text": "5 meterz",
"span": {
"start": 0,
"end": 8,
"text": "5 meterz"
},
"issues": [
{
"code": "TYPO_CORRECTED",
"severity": "warning",
"message": "Read \"meterz\" as m.",
"span": {
"start": 2,
"end": 8,
"text": "meterz"
},
"data": {
"unit": "meterz",
"corrected": "m"
}
}
],
"confidence": 0.85
}
},
"strictness:\"strict\"": {
"schemaVersion": 3,
"ok": false,
"type": "failure",
"text": "5 meterz",
"issues": [
{
"code": "UNKNOWN_UNIT",
"severity": "error",
"message": "Unknown unit \"meterz\"",
"span": {
"start": 2,
"end": 8,
"text": "meterz"
},
"data": {
"unit": "meterz"
}
}
]
}
}Turn any native input into a natural-language field: parse on input, never rewrite mid-typing, and submit one hidden canonical value on commit.
import { lingoInput } from "@pascal-app/lingo/dom"
const field = lingoInput(document.querySelector("#height"), {
kind: "length",
unit: "m",
name: "height_m",
errorElement: "#height-error",
hintElement: "#height-hint",
})
field.set("6ft")
field.commit()Fields never rewrite while typing; commits canonicalize once.
Text parses to thumbs; pointer and keyboard movement humanizes back to text.
Try 10kg +/-2 or drag either thumb.
{
"text": "8 to 12 kg",
"parser": {
"kind": "mass",
"unit": "kg"
},
"slider": {
"min": 0,
"max": 30,
"step": 0.1,
"value": [
8,
12
]
},
"display": "8 to 12 kg"
}const [text, setText] = useState("8 to 12 kg")const [range, setRange] = useState<[number, number]>([8, 12]) parseRange(text, { kind: "mass", unit: "kg" }) <Slider min={0} max={30} step={0.1} value={range} onValueChange={(next) => { setRange(next) setText(`${next[0]} to ${next[1]} kg`) }}/>One ref carries state, value, and programmatic control.
- state
- idle
- value
- null
const field = useLingoInput({ kind: "length", unit: "m", name: "react_height_m",})The DOM contract stays headless outside React.
useEffect(() => { const field = lingoInput(input, { kind: "mass", unit: "kg", name: "vanilla_mass_kg", }) return () => field.destroy()}, [])Native validation blocks submit after parser commit.
lingoInput(input, { kind: "length", unit: "m", required: true, validationBehavior: "native",})Canonical rewrites; echo formats; preserve keeps user text.
lingoInput(input, { kind: "length", unit: "m", display: "canonical" | "echo" | "preserve",})Bounds fail at commit with spans on original text.
lingoInput(input, { kind: "length", unit: "m", min: "50cm", max: "8ft", required: true,})A rejected range remains available as a candidate.
{
"schemaVersion": 3,
"ok": true,
"type": "range",
"kind": "mass",
"baseUnit": "kg",
"text": "between 5 and 10 kg",
"span": {
"start": 8,
"end": 19,
"text": "5 and 10 kg"
},
"issues": [],
"confidence": 1,
"min": {
"value": 5,
"unit": "kg",
"base": 5
},
"max": {
"value": 10,
"unit": "kg",
"base": 10
}
}parseRange(text, { kind: "mass" }) lingoInput(input, { kind: "mass", unit: "kg", accept: { ranges: false }, strictness: "confirm",})Automation sets visible text; FormData submits canonical value.
{}const field = lingoInput(input, { kind: "length", unit: "m", name: "agent_height_m",}) field.set("6ft")field.set(1.8)Web component
No framework adapter? <lingo-input> is a form-associated custom element wrapping the same field through ElementInternals, so FormData, native labels, and :invalid behave like any control in Vue, Svelte, Angular, or plain HTML.
<script type="module">
import { defineLingoInput } from '@pascal-app/lingo/element'
defineLingoInput()
</script>
<form>
<label for="height">Height</label>
<lingo-input id="height" name="height_m" kind="length" unit="m"></lingo-input>
</form>
<!-- committing 5'11" submits height_m=1.8034 -->Wire the same field into React Hook Form, TanStack Form, Formik, Vue, Angular, shadcn/ui, or vanilla — one field contract, every stack.
One live preview field, then a grouped snippet picker: React Hook Form, TanStack Form, shadcn/ui, Vue, Svelte, a Next.js server action, and more — all feeding the same field contract.
The code below swaps adapters; the field contract stays the same.
<form method="post" action="/signup" novalidate>
<label for="height">Height</label>
<input
id="height"
name="height"
inputmode="text"
placeholder="5'11" or 180cm"
required
/>
<p id="height-error" role="alert"></p>
<p id="height-hint" aria-hidden="true"></p>
<button>Continue</button>
</form>
<script type="module">
import { lingoInput } from "@pascal-app/lingo/dom"
lingoInput(document.querySelector("#height"), {
kind: "length",
unit: "m",
name: "height",
min: "0.3m",
max: "2.5m",
required: true,
validationBehavior: "aria",
errorElement: "#height-error",
hintElement: "#height-hint",
})
</script>Drop the unit dropdown: collapse one value plus one unit picker into a single natural-language field that still stores the canonical number.
This wins for one value + one unit pair. Keep the form structure people expect, but stop making each value carry a separate required unit picker.
Compare the control count and escalation surface before and after unit-aware parsing.
Treasury order review
Rates, fees, and internal USD limits need canonical values before approval.
Without lingo
Value box plus unit picker.
Rate mode and currency controls can drift from the number a reviewer sees.
With lingo
One text field per value.
Built-in currency kind; no live FX implied.
Accepts bps or percent and stores percent.
Duration stays deterministic.
One review form stores USD, percent, and days without a mode toggle.
- Budget cap
- $1250.00
- Rate move
- 0.25%
- Settlement lag
- 2 d
Currency, force, and torque are built in. The package keeps live FX and arbitrary dimensional algebra out of the runtime.
Let the model emit natural-language strings, then validate them with the same parser the UI uses — under stricter tool-boundary defaults for ambiguity, bounds, and closed schemas.
Constrained decoding can make JSON parse. The parser checks whether the values mean what they should.
JSON mode can't stop "2kg" landing in a number field (Number("2kg") → NaN, z.coerce.number() → NaN too), "1,5" losing its locale, or new Date("03/04/2025") silently picking the wrong month.
Models are better at emitting "5'11\"" than 1.8034 ; @pascal-app/lingo/ai makes that string the reliable path.
Tool-boundary defaults: genuinely ambiguous numbers and ignored timezones fail with a machine-actionable candidate, reference-dependent dates require an explicit now, bounds are enforced and advertised in the schema, object schemas are closed, and absorbed typo fixes surface as warnings.
Model output, canonicalized
Edit the textarea; the right panel shows the canonical tool payload.
Providers
The same lingoObject is a Standard Schema. Standard-Schema-aware libraries take it directly; raw SDKs get the same input schema through toJSONSchema().
Workflows
import { generateText, Output, tool } from "ai" // ai@^6 or ai@^7
import {
dateField,
lingoObject,
optional,
quantityField,
rangeField,
} from "@pascal-app/lingo/ai"
const schema = lingoObject({
weight: quantityField({ kind: "mass", unit: "kg", min: 0, max: 500 }),
height: quantityField({ kind: "length", unit: "m" }),
deliverBy: dateField({ now }),
boxWeight: rangeField({ kind: "mass", unit: "kg" }),
tareWeight: optional(quantityField({ kind: "mass", unit: "kg", min: 0 })),
carrier: "string",
})
const createShipment = tool({
description: "Create a shipment record.",
inputSchema: schema,
execute: async (input) => warehouse.create(input),
})
const { output } = await generateText({
model,
tools: { createShipment },
output: Output.object({ schema }),
prompt: "Extract shipment details from this note.",
})
// output.weight is kg, deliverBy is ISO, tareWeight is number | null.
// AI SDK v5 needs the SDK's jsonSchema() wrapper around this schema.import { generateText, tool } from "ai"
import {
lingoObject,
quantityField,
repairToolCallWith,
} from "@pascal-app/lingo/ai"
const shipSchema = lingoObject({
weight: quantityField({ kind: "mass", unit: "kg", min: 0 }),
})
const repairToolCall = repairToolCallWith({ ship: shipSchema })
await generateText({
model,
tools: {
ship: tool({
description: "Create a shipment.",
inputSchema: shipSchema,
execute: async ({ weight }) => ({ ok: true, kg: weight }),
}),
},
prompt,
experimental_repairToolCall: repairToolCall,
})
// experimental_repairText is the deprecated v5 generateObject path.import {
canonicalizeValues,
dateField,
quantityField,
rangeField,
} from "@pascal-app/lingo/ai"
const repaired = canonicalizeValues(toolCall.args, {
weight: quantityField({ kind: "mass", unit: "kg" }),
height: quantityField({ kind: "length", unit: "m" }),
deliverBy: dateField({ now }),
boxWeight: rangeField({ kind: "mass", unit: "kg" }),
})
// severity: "error" blocked the path; warnings rode along on applied values.
const errors = repaired.issues.filter((issue) => issue.severity === "error")
if (errors.length > 0) {
return { ok: false, issues: errors }
}
return createShipment(repaired.value)import { dateField, quantityField } from "@pascal-app/lingo/ai"
import { lingoTool } from "@pascal-app/lingo/mcp"
const createShipment = lingoTool({
name: "create_shipment",
description: "Create a shipment; natural-language values welcome.",
input: {
weight: quantityField({ kind: "mass", unit: "kg", min: 0, max: 500 }),
deliverBy: dateField({ min: "2026-01-01" }),
},
handler: ({ weight, deliverBy }) =>
"Shipment logged: " + weight + " kg, deliver by " + deliverBy,
})
server.registerTool(createShipment.name, {
description: createShipment.description,
inputSchema: createShipment.inputSchema,
}, createShipment.callback)import OpenAI from "openai"
import { dateField, lingoObject, quantityField, toJSONSchema } from "@pascal-app/lingo/ai"
const client = new OpenAI()
const schema = lingoObject({
sku: "string",
weight: quantityField({ kind: "mass", unit: "kg", min: 0 }),
deliverBy: dateField({ now: new Date() }),
})
const completion = await client.chat.completions.create({
model: "gpt-5.5",
messages: [{ role: "user", content: "Ship SKU A100, 5 kg, tomorrow." }],
tools: [{
type: "function",
function: {
name: "create_shipment",
description: "Create a shipment for one order line.",
parameters: toJSONSchema(schema),
strict: true,
},
}],
})
const call = completion.choices[0]?.message.tool_calls?.[0]
if (call?.type === "function" && call.function.name === "create_shipment") {
const parsed = schema.safeParse(JSON.parse(call.function.arguments))
if (!("value" in parsed)) return reportToolError(parsed.issues)
await warehouse.create(parsed.value)
}import Anthropic from "@anthropic-ai/sdk"
import { lingoObject, quantityField, toJSONSchema } from "@pascal-app/lingo/ai"
const schema = lingoObject({
weight: quantityField({ kind: "mass", unit: "kg", min: 0, max: 1000 }),
note: "string",
})
const toolDef: Anthropic.Tool = {
name: "log_shipment",
description: "Record a shipment weight and note.",
input_schema: toJSONSchema(schema) as Anthropic.Tool.InputSchema,
strict: true,
}
async function runTool(toolUse: Anthropic.ToolUseBlock): Promise<Anthropic.ToolResultBlockParam> {
const parsed = schema.safeParse(toolUse.input)
if ("issues" in parsed) {
return {
type: "tool_result",
tool_use_id: toolUse.id,
content: parsed.issues.map((issue) => issue.message).join("; "),
is_error: true,
}
}
await warehouse.log(parsed.value)
return { type: "tool_result", tool_use_id: toolUse.id, content: "Logged." }
}import { GoogleGenAI } from "@google/genai"
import { quantityField, toJSONSchema } from "@pascal-app/lingo/ai"
const weight = quantityField({ kind: "mass", unit: "kg", min: 0 })
const ai = new GoogleGenAI({})
await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: "Ship 5 kg tomorrow.",
config: {
tools: [{
functionDeclarations: [{
name: "create_shipment",
description: "Create a shipment record.",
// Use parametersJsonSchema; never classic parameters/responseSchema.
parametersJsonSchema: {
type: "object",
properties: { weight: toJSONSchema(weight) },
required: ["weight"],
additionalProperties: false,
},
}],
}],
},
})import { ChatOpenAI } from "@langchain/openai"
import { createAgent, toolStrategy } from "langchain"
import { canonicalizeValues, lingoObject, quantityField } from "@pascal-app/lingo/ai"
const weight = quantityField({ kind: "mass", unit: "kg", min: 0 })
const schema = lingoObject({ weight })
const extract = new ChatOpenAI({ model: "gpt-4o-mini" }).withStructuredOutput(schema)
const result = await extract.invoke("about 40 lbs")
// result.weight is canonical kg when the model override validates Standard Schema.
const agent = createAgent({
model: "gpt-4o-mini",
tools: [],
responseFormat: toolStrategy(schema, { handleError: true }),
})
const { structuredResponse } = await agent.invoke({ messages })
// createAgent + toolStrategy validate shape only; canonicalize values yourself.
const { value, issues } = canonicalizeValues(structuredResponse, { weight })import { dateMatch, quantityMatch } from "@pascal-app/lingo/ai"
quantityMatch("2 lbs", "0.90718474 kg", {
kind: "mass",
unit: "kg",
tolerance: 0.02,
})
// { pass: true, score: 1, reason: "Values match within relative tolerance 0.02." }
dateMatch("July 4 2026", "2026-07-04", {
grain: "day",
timeZone: "UTC",
})
// { pass: true, score: 1, reason: "Dates match at day grain." }import { lingoInput } from "@pascal-app/lingo/dom"
const input = document.querySelector<HTMLInputElement>("#height")!
const field = lingoInput(input, {
kind: "length",
unit: "m",
name: "height_m",
})
// Verified on the docs form: 5'11" commits hidden height_m=1.8034.
field.set("5'11\"")
field.commit()
field.value // 1.8034
new FormData(input.form!).get("height_m") // "1.8034"Eval readout
Canonicalization-rate demo on a recorded corpus, not an end-to-end LLM benchmark.
- naive accept
- 18%
- lingo accept
- 97%
- naive silent-wrong
- 6%
- lingo silent-wrong
- 0%
Turn a field shape into a complete MCP tool descriptor: a closed JSON Schema out, safeParse in, and [CODE] errors the model can self-correct from.
lingoTool() from @pascal-app/lingo/mcp builds the whole registerTool contract from a lingoObject shape. Bring any MCP SDK; lingo brings the schema and the validation.
import { dateField, quantityField } from "@pascal-app/lingo/ai"
import { lingoTool } from "@pascal-app/lingo/mcp"
const createShipment = lingoTool({
name: "create_shipment",
description: "Create a shipment; natural-language values welcome.",
input: {
weight: quantityField({ kind: "mass", unit: "kg", min: 0, max: 500 }),
deliverBy: dateField({ min: "2026-01-01" }),
},
handler: ({ weight, deliverBy }) =>
"Shipment logged: " + weight + " kg, deliver by " + deliverBy,
})
server.registerTool(createShipment.name, {
description: createShipment.description,
inputSchema: createShipment.inputSchema,
}, createShipment.callback)With requireNow on, "tomorrow" bounces back with NOW_REQUIRED, so a queued tool call can never drift across midnight.
Use one lingoObject to validate an LLM tool argument and canonicalize a human form, unchanged.
const shipment = lingoObject({
weight: quantityField({ kind: 'mass', unit: 'kg', min: 0 }),
deliverBy: dateField(),
})import { tool } from 'ai'
// LLM emits "5 kg" / "next friday"; canonical on arrival
tool({ inputSchema: shipment, execute: run })import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'
// user types "5 kg" / picks a date; canonical on submit
useForm({ resolver: standardSchemaResolver(shipment) })Type 5'11" or emit "5 kg" ; both arrive canonical. (Whole-form resolvers use lingoObject(shape, { passthrough: true }) for fields lingo doesn't own.)
Convert between units with exact legal factors — temperature deltas included — then render values back with best-fit and compound output.
import { convert, convertDelta, tryConvert, quantity } from "@pascal-app/lingo"
convert(72, "in", "ft") // 6 (exact legal factors)
convert(1, "gal", "L") // 3.785411784
convertDelta(5, "C", "F") // 9 (a difference, not 41)
const targetUnit: string = "cm" // a dynamic ref (the escape hatch)
tryConvert(5, "kg", targetUnit) // { ok: false, issues } instead of throwing
quantity(1500, "m").toBest().format() // "1.5 km"
quantity(1.8034, "m").format({ compound: ["ft", "in"] }) // "5′11″"
quantity(2, "ft").format({ style: "long" }) // "2 feet"convert throws on a bad pair; tryConvert returns a structured issue instead. convertDelta converts a difference — a 5 °C rise is a 9 °F rise, not 41. Everything format() emits re-parses to the same value.
Parse symbols, ISO codes, and slang; format via Intl; convert with rates you supply — lingo never bundles or fetches FX.
import { lingo, quantity, fromMinor, convertCurrency } from "@pascal-app/lingo"
lingo("$5", { currency: "CAD" }).quantity.unit // "CAD"
lingo("3 quid 50").quantity.value // 3.5 (GBP)
quantity(5, "USD").format() // "$5.00"
quantity(5, "USD").toMinor() // 500 (Stripe minor units)
fromMinor(500, "USD").value // 5
// Convert with YOUR rates — lingo never bundles or fetches FX.
convertCurrency(100, "USD", "EUR", {
rates: { base: "USD", rates: { EUR: 0.92 } },
}) // 92Bare $ and cents stay ambiguous — an AMBIGUOUS_UNIT warning — until you pass a currency context. Cross-currency convert() without rates fails with RATE_REQUIRED, never a silent wrong answer.
Relative dates parse against an explicit now for reproducible results, and the humanizer's output always re-parses within one grain.
import {
parseDate,
parseDateRange,
humanizeDate,
humanizeDateRange,
parseDuration,
humanizeDuration,
} from "@pascal-app/lingo/date"
parseDate("three days ago", { now }).date // exact Date, grain "day"
parseDate("17h30", { now }) // 17:30 today (grain "minute")
parseDate("quarter past 5", { now }) // 05:15
humanizeDate(d, { now }) // "3 days ago" — re-parseable
// Timezones: exposed by default; opt in to resolve the instant
parseDate("3pm EST", { now }).zone // { source: "abbrev", offsetMinutes: -300 }
parseDate("3pm EST", { now, applyZone: true }) // the real UTC instant, not host-local
// Time slots, two-way
const slot = parseDateRange("2pm to 4pm", { now }) // { start, end } endpoints
parseDateRange("9-5", { now }) // workday shift → 09:00–17:00
humanizeDateRange(slot) // "2:00 PM to 4:00 PM"
parseDuration("1h30").duration.base // 5400 (seconds)
humanizeDuration(5400, { style: "natural" }) // "an hour and a half"Import from @pascal-app/lingo/date. Times of day read the way people write them (17h, quarter past 5, 5.30pm, midi); a trailing timezone is exposed on .zone and resolved with applyZone; and parseDateRange turns a slot like 2pm to 4pm or 9-5 into { start, end }. Reference-dependent input needs an explicit now, so a queued job parses the same date every time. Browse what it reads under Date shorthand and Time slots.
Browse every kind, unit, alias, and fuzzy vocabulary the parser understands, plus the deterministic date shorthand it reads.
Kinds
Aliases, unicode, and compounds normalize to one canonical base.
{
"schemaVersion": 3,
"ok": true,
"type": "quantity",
"kind": "length",
"value": 1.8,
"unit": "m",
"base": 1.8,
"baseUnit": "m",
"text": "1m80",
"span": {
"start": 0,
"end": 4,
"text": "1m80"
},
"issues": [],
"confidence": 1,
"parts": [
{
"unit": "m",
"value": 1
},
{
"unit": "cm",
"value": 80
}
]
}Fuzzy words parse only when a kind supplies vocabulary.
freezing: -90-0 °Cice cold: -90-4 °Ccold: 0-10 °Cchilly: 3-12 °Ccool: 10-17 °Cmild: 15-22 °Cwarm: 20-28 °Chot: 27-35 °Cvery hot: 33-42 °Cscorching: 40-55 °Cboiling: 40-55 °Cfreezing: 0-4 °Cice cold: 0-8 °Ccold: 8-18 °Ccool: 18-24 °Clukewarm: 30-37 °Cwarm: 37-42 °Chot: 42-50 °Cvery hot: 50-60 °Cscalding: 60-100 °Cboiling: 95-100 °Ccool: 90-140 °Cslow: 90-150 °Cwarm: 140-170 °Cmoderate: 170-190 °Chot: 190-230 °Cvery hot: 230-260 °C[
{
"profile": "weather",
"unit": "C",
"terms": {
"freezing": [
-90,
0
],
"ice cold": [
-90,
4
],
"cold": [
0,
10
],
"chilly": [
3,
12
],
"cool": [
10,
17
],
"mild": [
15,
22
],
"warm": [
20,
28
],
"hot": [
27,
35
],
"very hot": [
33,
42
],
"scorching": [
40,
55
],
"boiling": [
40,
55
]
}
},
{
"profile": "water",
"unit": "C",
"terms": {
"freezing": [
0,
4
],
"ice cold": [
0,
8
],
"cold": [
8,
18
],
"cool": [
18,
24
],
"lukewarm": [
30,
37
],
"warm": [
37,
42
],
"hot": [
42,
50
],
"very hot": [
50,
60
],
"scalding": [
60,
100
],
"boiling": [
95,
100
]
}
},
{
"profile": "oven",
"unit": "C",
"terms": {
"cool": [
90,
140
],
"slow": [
90,
150
],
"warm": [
140,
170
],
"moderate": [
170,
190
],
"hot": [
190,
230
],
"very hot": [
230,
260
]
}
}
]Reference time is explicit, so relative dates stay deterministic.
[
{
"input": "today",
"value": "2026-07-03T00:00:00Z",
"issues": []
},
{
"input": "tomorrow",
"value": "2026-07-04T00:00:00Z",
"issues": []
},
{
"input": "three days ago",
"value": "2026-06-30T12:00:00Z",
"issues": []
},
{
"input": "next tues",
"value": "2026-07-07T00:00:00Z",
"issues": []
},
{
"input": "5/3",
"value": "2027-03-05T00:00:00Z",
"issues": [
{
"code": "AMBIGUOUS_DATE",
"severity": "warning",
"message": "\"5/3\" could be 2027-03-05 or 2027-05-03 — assuming 2027-03-05.",
"span": {
"start": 0,
"end": 3
},
"data": {
"text": "5/3",
"a": "2027-03-05",
"b": "2027-05-03"
}
}
]
},
{
"input": "at 3pm",
"value": "2026-07-03T15:00:00Z",
"issues": []
},
{
"input": "17h30",
"value": "2026-07-03T17:30:00Z",
"issues": []
},
{
"input": "quarter past 5",
"value": "2026-07-04T05:15:00Z",
"issues": []
},
{
"input": "3pm EST",
"value": "2026-07-03T15:00:00Z",
"issues": [
{
"code": "TZ_IGNORED",
"severity": "warning",
"message": "Time zone \"EST\" detected but not applied — pass applyZone to resolve the instant.",
"span": {
"start": 4,
"end": 7
},
"data": {
"tz": "EST"
}
},
{
"code": "AMBIGUOUS_TIMEZONE",
"severity": "warning",
"message": "Time zone \"EST\" is ambiguous — use an explicit offset or IANA name.",
"span": {
"start": 4,
"end": 7
},
"data": {
"tz": "EST"
}
}
]
},
{
"input": "in 2d",
"value": "2026-07-05T12:00:00Z",
"issues": []
},
{
"input": "3min from tmrw",
"value": "2026-07-04T12:03:00Z",
"issues": []
}
]Time slots parse to start/end endpoints; am/pm is inferred across the pair, and 9-5 reads as the workday shift.
[
{
"input": "2pm to 4pm",
"value": "14:00 – 16:00",
"issues": []
},
{
"input": "between 9am and 5pm",
"value": "9:00 – 17:00",
"issues": []
},
{
"input": "9-5",
"value": "9:00 – 17:00",
"issues": []
},
{
"input": "2 to 4pm",
"value": "14:00 – 16:00",
"issues": []
},
{
"input": "from 3pm",
"value": "15:00 – —",
"issues": []
},
{
"input": "10pm to 2am",
"value": "22:00 – 2:00",
"issues": []
}
]See what the benchmark means in product terms: fast enough for typing, fast enough for imports, with the local machine details attached.
On this run, a normal field parsed 492,739 values per second, about 2.03 µs each. The corpus is generated from built-in unit aliases, number forms, qualifiers, ranges, conversions, typos, dates, durations, and sentence templates. Bars show inputs parsed per second, so longer is better. The µs/op line is the time for one input, so smaller is better; the cases count is the number of distinct generated inputs in that suite. This is a local backend snapshot, not a promise that every device will match it.
- Typing fields
- 492,739 values/s
About 2.03 µs each across 900 cases.
- Bulk imports
- 550,270 checks/s
Strict validation without suggestions takes 1.82 µs per row across 240 cases.
- Messy text
- 53,533 scans/s
Sentence scanning cycles 260 cases; 50k no-match finished in 0.341 ms.
common parsing
typing feedback
dates and durations
formatting
helpful errors
bulk imports
confirmation flow
sentence scanning
stress tests
@pascal-app/lingo@0.1.0 · v24.5.0 · darwin/arm64 · 2026-07-04 · generated-english v2 · pnpm bench:backend
Register custom kinds and units, define fuzzy vocab, isolate instances for SSR or multi-tenant apps, and bring your own message copy.
import { registerUnits, defineFuzzyVocab, createLingo } from "@pascal-app/lingo"
registerUnits("length", [
{ id: "smoot", symbol: "smoot", name: "smoot", factor: 1.702, system: "us" },
])
defineFuzzyVocab("mass", {
profile: "parcels",
unit: "kg",
terms: { light: [0, 5], heavy: [20, 70] },
})
// Isolated instance for SSR / multi-tenant / tests — no global leaks.
const tenant = createLingo({ messages: { UNKNOWN_UNIT: "Metric units only." } })
tenant.parse("5 kg")Custom kinds get parsing, conversion, formatting, and did-you-mean for free — like a per-order package_count kind:
import { allKinds, createLingo } from '@pascal-app/lingo'
const appLingo = createLingo({
kinds: [
...allKinds,
{
kind: 'package_count',
baseUnit: 'item',
units: [
{ id: 'item', symbol: 'item', name: 'item', factor: 1, system: 'shared' },
{ id: 'case', symbol: 'case', name: 'case', factor: 24, system: 'shared' },
],
},
],
})
appLingo.parseQuantity('3 cases', { kind: 'package_count', unit: 'item' })For a minimal build, @pascal-app/lingo/core ships the engine with an empty registry and no English copy — register it with setDefaultMessages(englishMessages) or supply your own message pack per call.
An opt-in, self-explaining view of a value or result for logs, docs, debugging, and tool output — object names, grouped fields, source text on spans, and rich unit labels.
import { lingo } from "@pascal-app/lingo"
import { describeResult } from "@pascal-app/lingo/describe"
describeResult(lingo("72 in to cm")).data
// {
// object: "lingo.conversion",
// source: { object: "lingo.quantity", value: { amount: 72, unit: { id: "in", ... } } },
// target: { unit: { id: "cm", name: "centimeter" } },
// converted: { object: "lingo.quantity", value: { amount: 182.88, unit: { id: "cm", ... } } },
// }
// Keep toJSON() for compact wire storage; describe* is the readable view.describeResource() and describeResult() live in @pascal-app/lingo/describe. They don't replace compact toJSON()wire storage — they're the human- and agent-readable counterpart.
Reference the core functions, option knobs, issue codes, and the literal-typed unit refs used by the demos.
Data schemas
Every result serializes to a flat, versioned shape (schemaVersion: 3). These are the canonical wire types — what JSON.stringify(result), toJSON(), and the /ai fields emit and re-parse. Every span is a half-open [start, end) range into the original input.
// lingo() / parse*() return a discriminated union on `type`, serialized
// flat (v3). JSON.stringify(result) or result.toJSON() emits this shape.
interface Result { // success — ok: true
schemaVersion: 3
ok: true
type: "quantity" | "range" | "conversion" | "number"
text: string // the original input
span: Span // the slice the parse consumed
confidence: number // 0 to 1
issues: Issue[] // warnings and infos ride along on success
// ...plus the value fields for its `type` (see the Quantity / Range tabs)
}
interface Failure { // ok: false
schemaVersion: 3
ok: false
type: "failure"
text: string
issues: Issue[] // at least one has severity: "error"
candidate?: Result // "what it would have been", when recoverable
}// One value in one unit, stored canonically as `base`. Quantity.toJSON():
interface QuantityJSON {
schemaVersion: 3
type: "quantity"
kind: Kind // "length" | "mass" | "currency" | ...
value: number // amount in `unit` (the 72 in "72 in")
unit: string // "in"
base: number // canonical amount (1.8288) — the source of truth
baseUnit: string // the kind's SI-anchored base ("m")
parts?: { value: number; unit: string }[] // compound input: "5 ft 11 in"
approximate?: boolean // "about 5 kg"
}// A min/max, plus-or-minus, or fuzzy range. QuantityRange.toJSON():
interface QuantityRangeJSON {
schemaVersion: 3
type: "range"
kind: Kind
baseUnit: string
min?: { value: number; unit: string; base: number; exclusive?: boolean }
max?: { value: number; unit: string; base: number; exclusive?: boolean }
plusMinus?: { // "10 ± 0.5 mm"
center: { value: number; unit: string; base: number }
delta: { value: number; unit: string; base: number }
}
fuzzy?: { term: string; profile: string } // { term: "hot", profile: "weather" }
approximate?: boolean // "a few minutes"
}// The one error / warning / info shape on every result's issues[].
interface Issue {
code: IssueCode // stable SCREAMING_SNAKE, e.g. "UNKNOWN_UNIT"
severity: "error" | "warning" | "info"
message: string // human copy — override with the `messages` option
span?: Span // where in the input; absent for /ai bound issues
suggestions?: string[] // did-you-mean, most likely first (max 3)
data?: object // code-specific payload, typed via IssueDataMap
}
// A half-open [start, end) character range into the ORIGINAL input string.
interface Span {
start: number
end: number
text: string // input.slice(start, end) — reads for itself
}Issue codes
Type safety
Unit refs are literal-typed from the registry, so cross-kind and unknown-unit mistakes are compile errors at zero runtime cost. Dynamic strings still work as the escape hatch.
import { convert, quantity } from "@pascal-app/lingo"
convert(5, "in", "cm") // ✅ number
quantity(5, "kg") // ✅ Quantity<"mass"> — kind inferred from the unit
// @ts-expect-error 'kg' is mass, 'cm' is length — caught before you run
convert(5, "kg", "cm")
// @ts-expect-error 'nope' isn't a unit
quantity(5, "nope")
const u: string = userInput
quantity(5, u) // ✅ dynamic strings still compile (validated at runtime)