Skip to content
lingo

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.

Actually parses languageNumber words, unicode, typos, compounds, ranges, conversions, and fuzzy words.
Canonical underneathEvery value normalizes to an SI-anchored base with exact legal factors.
Two-wayEverything format() and humanize*() emit re-parses to the same value.
Honest about ambiguityA deterministic best reading plus ranked alternatives — never a silent guess.
Errors are UXEvery issue carries a stable code, a message, an input span, and suggestions.
Tiny, zero-depNo runtime dependencies. Tree-shakeable entries. Intl for locale formatting.

Install the package, then import from the entry that matches your runtime. Every entry is tree-shakeable.

pnpm add @pascal-app/lingo

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

state: valid
182.88 cm
parsed
length -> cm
confidence100%
no issues
jsonRaw JSON
{
  "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 and number format variants

System picks the gallon family; numberFormat resolves separator ambiguity.

1234 gal
parsed
volume
confidence80%
warning:AMBIGUOUS_NUMBER [0,5)

AMBIGUOUS_NUMBER: "1,234" could mean 1234 or 1.234 — assuming 1234.

jsonVariant output
{
  "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.

ts/find.ts
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.

ts/strictness.ts
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()
}
Strictness comparison

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.

forgiving
ok=true
5 m
parsed
length
confidence85%
warning:TYPO_CORRECTED [2,8)

TYPO_CORRECTED: That looks like a unit typo. Confirm before saving.

confirm
ok=false
Rejected: 5 m
blocked
quantity candidate
confidence0%
error:TYPO_CORRECTED [2,8)

TYPO_CORRECTED: That looks like a unit typo. Confirm before saving.

strict
ok=false
No parse
blocked
failed
confidence0%
error:UNKNOWN_UNIT [2,8)

UNKNOWN_UNIT: Try cm, m, ft, in, or kg.

jsonStrictness output
{
  "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"
        }
      }
    ]
  }
}
Acceptance controls

Switches reject shapes while preserving the candidate parse.

Acceptance and tolerance

Reject parse shapes without discarding candidates.

Message overrides

Messages pass into every parser call.

jsonControls JSON
{
  "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 variants

Strictness changes issue severity, not grammar.

5 m
parsed
length
confidence85%
warning:TYPO_CORRECTED [2,8)

TYPO_CORRECTED: Read "meterz" as m.

jsonVariant output
{
  "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.

ts/dom-field.ts
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()
DOM and React forms

Fields never rewrite while typing; commits canonicalize once.

Bidirectional range slider

Text parses to thumbs; pointer and keyboard movement humanizes back to text.

Try 10kg +/-2 or drag either thumb.

Minimum weightMaximum weightCurrent range 27 to 40 percent
0 kg30 kg
Selected 8 to 12 kg
jsonView config
{
  "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"
}
tsx
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`)  }}/>
React hook

One ref carries state, value, and programmatic control.

state
idle
value
null
data attributes
unbound
tsx
const field = useLingoInput({  kind: "length",  unit: "m",  name: "react_height_m",})
Vanilla controller

The DOM contract stays headless outside React.

data attributes
unbound
tsx
useEffect(() => {  const field = lingoInput(input, {    kind: "mass",    unit: "kg",    name: "vanilla_mass_kg",  })  return () => field.destroy()}, [])
Native validation

Native validation blocks submit after parser commit.

not submitted

data attributes
unbound
tsx
lingoInput(input, {  kind: "length",  unit: "m",  required: true,  validationBehavior: "native",})
Display modes

Canonical rewrites; echo formats; preserve keeps user text.

unbound
unbound
unbound
tsx
lingoInput(input, {  kind: "length",  unit: "m",  display: "canonical" | "echo" | "preserve",})
Constraints

Bounds fail at commit with spans on original text.

data attributes
unbound
tsx
lingoInput(input, {  kind: "length",  unit: "m",  min: "50cm",  max: "8ft",  required: true,})
Range vs single

A rejected range remains available as a candidate.

5–10 kg
parsed
mass
confidence100%
no issues
jsonRaw JSON
{
  "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
  }
}
data attributes
unbound
tsx
parseRange(text, { kind: "mass" }) lingoInput(input, {  kind: "mass",  unit: "kg",  accept: { ranges: false },  strictness: "confirm",})
Agent and hidden value

Automation sets visible text; FormData submits canonical value.

data attributes
unbound
FormData preview
jsonFormData preview
{}
tsx
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.

html/lingo-input.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.

Integration preview

The code below swaps adapters; the field contract stays the same.

Natural-language field
One ref exposes partial state and the canonical meter value.
state
idle
meters
null
unbound
Browser
React forms
Runtimes
html/index.html
<form method="post" action="/signup" novalidate>
  <label for="height">Height</label>
  <input
    id="height"
    name="height"
    inputmode="text"
    placeholder="5'11&quot; 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.

Before and after examples

TopicWithout lingoWith lingo
FinanceBudget value plus currency picker, rate value plus %/bps mode, settlement value plus duration unit.
1250 usd25 bps2 days

The app stores USD, percent, and days. Currency is built in; no live FX implied.

RecipesIngredient amount plus cup/mL/L picker, oil amount plus tsp/tbsp/mL picker, cook-time picker.
1.5 cups2 tbsp45 min

The app stores mL and minutes while accepting prep-sheet wording.

EngineeringClearance, pressure, axial load, and torque each require their own unit selector.
3/4 in32 psi120 lbf35 Nm

The app stores mm, kPa, N, and N⋅m. Force and torque are built in.

FitnessBody weight, distance, duration, and energy each carry a separate mode choice.
165 lb5 km42 min450 Calories

The app stores kg, meters, minutes, and kcal for analytics.

MedicalDose volume, interval, height, and cuff pressure selectors create a noisy intake layout.
1.5 tsp6 hr5'11"120 mmHg

The app stores field-local canonical values; medication units remain domain-owned.

Real form shapes

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.

6 controls
Escalates late

Rate mode and currency controls can drift from the number a reviewer sees.

With lingo

One text field per value.

0 blocking

Built-in currency kind; no live FX implied.

parsed$1250.00

Accepts bps or percent and stores percent.

parsed0.25%

Duration stays deterministic.

parsed2 d
0 field-local hintsno unit dropdowns

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.

now: 2026-07-03 14:30
jsoncanonical JSON

Providers

Vercel AI SDKPass a field into `tool()` / `Output.object()` with no Zod (v6/v7).
OpenAIStrict function tools via `toJSONSchema()`; `safeParse` the args.
Anthropic`input_schema` + `is_error` tool results.
Google Gemini`parametersJsonSchema`; never the classic surface.
LangChain`withStructuredOutput()`; `canonicalizeValues` after `createAgent`.
MCP`lingoTool()` gives you a full `registerTool` contract.

The same lingoObject is a Standard Schema. Standard-Schema-aware libraries take it directly; raw SDKs get the same input schema through toJSONSchema().

Workflows

Tool callsValidate and canonicalize model arguments at the boundary.
Tool repair`repairToolCallWith()` fixes `"2kg"` → `2` with no extra model call.
Data collection`canonicalizeValues()` over extracted records before a DB write.
Evals`quantityMatch` / `dateMatch` grade unit and date equivalence.
Computer useAgents type `5'11"`; the field commits `1.8034`.
ts/extract-shipment.ts
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.

Eval readout

Canonicalization-rate demo on a recorded corpus, not an end-to-end LLM benchmark.

160 fixtures · v24.15.0
naive accept
18%
lingo accept
97%
naive silent-wrong
6%
lingo silent-wrong
0%
Acceptance rate and silent-wrong rate by category; metrics are not blended.
CategoryCountNaive acceptLingo acceptNaive silent-wrongLingo silent-wrong
unit omission16100%
100%
0%
0%
unit in number field160%
100%
0%
0%
locale decimal comma160%
100%
0%
0%
thousands separator1613%
69%
13%
0%
date drift nl in date field1650%
100%
50%
0%
scientific notation1613%
100%
0%
0%
range in scalar160%
100%
0%
0%
qualifier leakage160%
100%
0%
0%
typod slang units160%
100%
0%
0%
compound mixed unit160%
100%
0%
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.

ts/mcp-tool.ts
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.

ts/schema.ts
const shipment = lingoObject({
  weight: quantityField({ kind: 'mass', unit: 'kg', min: 0 }),
  deliverBy: dateField(),
})
ts/tool.ts
import { tool } from 'ai'

// LLM emits "5 kg" / "next friday"; canonical on arrival
tool({ inputSchema: shipment, execute: run })
tsx/form.tsx
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.

ts/convert.ts
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.

ts/currency.ts
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 } },
})                                                // 92

Bare $ 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.

ts/dates.ts
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

Built-in lingo unit kinds
KindBaseUnitsSample aliases
lengthm21nm, nanometre, nanometres, μm
masskg14μg, microgramme, microgrammes, ug
temperatureK4K, °C, °c, ° c
durations13ns, μs, µs, usec
volume26µL, μl, mL, millilitre
area11mm², mm^2, sq mm, sq. mm.
speedm/s5m/s, mps, m/sec, metre per second
dataB16bit, B, kbit, Mbit
data_ratebit/s13bit/s, bit/sec, bits/sec, bits/s
flow_ratem³/s11m³/s, m^3/s, m3/sec, m^3/sec
accelerationm/s²4cm/s², cm/s^2, cm/s2, centimetre per second squared
pressurePa17mPa, Pa, hPa, mbar
energyJ12mJ, J, kJ, MJ
forceN8μN, mN, N, kN
torqueN⋅m3N⋅m, N m, N*m, N·m
powerW8mW, W, kW, MW
frequencyHz7mHz, Hz, kHz, MHz
anglerad6rad, °, deg, arcmin
percent%3%, pct, per cent, percentage point
luminous_intensitycd3mcd, cd, kcd
luminous_fluxlm2lm, klm
illuminancelx3lx, klx, fc, foot candle
luminancecd/m²3cd/m², cd/m^2, cd/m2, candela per square metre
voltageV5μV, microvolts, uV, mV
currentA5μA, microamperes, microamp, microamps
resistanceΩ4mΩ, milliohms, Ω, ohms
chargeC6μC, microcoulombs, mC, millicoulombs
substancemol4μmol, micromoles, umol, mmol
concentrationmol/m³9mol/m³, mol/m^3, M, mM
radiation_absorbed_doseGy3μGy, mGy, Gy
radiation_equivalent_doseSv4μSv, mSv, Sv, rem
radioactivityBq6Bq, kBq, MBq, Ci
currency$28$, usd, us dollar, us dollars
Alias roulette

Aliases, unicode, and compounds normalize to one canonical base.

Current input
1m80
1 m 80 cm
parsed
length
confidence100%
no issues
jsonAlias output
{
  "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 bands

Fuzzy words parse only when a kind supplies vocabulary.

weather
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 °C
water
freezing: 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 °C
oven
cool: 90-140 °Cslow: 90-150 °Cwarm: 140-170 °Cmoderate: 170-190 °Chot: 190-230 °Cvery hot: 230-260 °C
jsonFuzzy vocab
[
  {
    "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
      ]
    }
  }
]
Date shorthand

Reference time is explicit, so relative dates stay deterministic.

today2026-07-03T00:00:00Z
tomorrow2026-07-04T00:00:00Z
three days ago2026-06-30T12:00:00Z
next tues2026-07-07T00:00:00Z
5/32027-03-05T00:00:00Z
at 3pm2026-07-03T15:00:00Z
17h302026-07-03T17:30:00Z
quarter past 52026-07-04T05:15:00Z
3pm EST2026-07-03T15:00:00Z
in 2d2026-07-05T12:00:00Z
3min from tmrw2026-07-04T12:03:00Z
jsonDate output
[
  {
    "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

Time slots parse to start/end endpoints; am/pm is inferred across the pair, and 9-5 reads as the workday shift.

2pm to 4pm14:00 – 16:00
between 9am and 5pm9:00 – 17:00
9-59:00 – 17:00
2 to 4pm14:00 – 16:00
from 3pm15:00 – —
10pm to 2am22:00 – 2:00
jsonSlot output
[
  {
    "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

single-value field492,739 ops/s2.03 µs/op · 900 casesmixed natural input389,820 ops/s2.57 µs/op · 791 cases

typing feedback

as-you-type check275,519 ops/s3.63 µs/op · 474 cases

dates and durations

humanize duration193,728,054 ops/s<0.01 µs/op · 1 caseshumanize a date2,422,248 ops/s0.41 µs/op · 1 casesdurations people type843,289 ops/s1.19 µs/op · 169 casesdates people type339,669 ops/s2.94 µs/op · 103 cases

formatting

format a value2,327,556 ops/s0.43 µs/op · 1 cases

helpful errors

fix a unit typo76,106 ops/s13.1 µs/op · 218 casessuggest nearby units25,460 ops/s39.3 µs/op · 240 cases

bulk imports

strict import check550,270 ops/s1.82 µs/op · 240 cases

confirmation flow

needs-review parse151,999 ops/s6.58 µs/op · 224 cases

sentence scanning

scan a sentence53,533 ops/s18.7 µs/op · 260 cases

stress tests

Stress testMedianTimed runs
very long text, no value341.1 µs11
long unknown unit160.5 µs11
huge number3.96 µs11

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

ts/extend.ts
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:

ts/custom-kinds.ts
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.

ts/describe.ts
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.

Core functions

ExportPurpose
lingo(text, opts?)Parse quantity, range, conversion, or number.
parseQuantity(text, opts?)Parse a single quantity; conversion requests resolve to target unit.
parseRange(text, opts?)Parse ranges; single values become degenerate ranges.
partialState(text, opts?)As-you-type state: empty, incomplete, valid, invalid.
findQuantities(text, opts?)Best-effort free-text scan with span offsets.
quantity(value, unitRef, kind?)Create a Quantity programmatically.
convert(value, from, to)Convert a plain number between units.
convertDelta(value, from, to)Convert differences without temperature offsets.
fromJSON(json)Rehydrate Quantity or QuantityRange JSON.

Options

OptionPurpose
kindBias unit resolution and restrict expected kind.
unitAssume a unit for bare numbers and set canonical field unit.
systemChoose US, imperial, or metric unit families.
numberFormatResolve ambiguous decimal and grouping separators.
strictnessforgiving, confirm, or strict.
acceptSwitch ranges, conversions, compounds, fuzzy, numberWords, approximations, bareNumbers.
tolerancetypos fix/suggest/off and ambiguity assume/confirm.
escalateMap issue codes to error, warning, or info.
messagesOverride human copy by issue code.

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.

ts/result.ts
// 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
}

Issue codes

EMPTYNO_VALUEUNKNOWN_UNITKIND_MISMATCHRANGE_KIND_MISMATCHCONVERSION_KIND_MISMATCHTRAILING_INPUTSINGLE_VALUE_EXPECTEDAPPROX_NOT_ALLOWEDUNIT_REQUIREDCONVERSION_NOT_ALLOWEDNUMBER_FORMATNONFINITERANGE_MINRANGE_MAXRANGE_OPEN_BOUND_NOT_ALLOWEDREQUIREDUNSUPPORTED_DATENOW_REQUIREDTYPO_CORRECTEDAMBIGUOUS_NUMBERAMBIGUOUS_UNITAMBIGUOUS_DATERANGE_REVERSEDCOMPOUND_OVERFLOWCIVIL_AVERAGEUNIT_ASSUMEDWEEKDAY_ASSUMED_NEXTSLANG_UNITTZ_IGNOREDAMBIGUOUS_TIMEZONE

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.

ts/type-safety.ts
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)