llm_schema 0.1.0
llm_schema: ^0.1.0 copied to clipboard
Zod-style schema validation for LLM structured outputs. Define a schema once to validate AI-generated JSON, emit JSON Schema for tool calling, and extract JSON from messy model responses. Pure Dart, n [...]
llm_schema #
Zod-style schema validation for LLM structured outputs, in pure Dart. Define a schema once and get all three:
- Typed validation of AI-generated JSON, with path-aware errors
(
$.items[2].price: Expected number, got string). - JSON Schema output (
toJsonSchema()) for tool/function definitions and structured-output APIs (Claude, OpenAI, Gemini). - Tolerant parsing of raw model text (
parseText) — Markdown code fences, prose around the JSON, and trailing commas are handled for you.
No code generation, no build_runner, no dependencies.
Quick start #
import 'package:llm_schema/llm_schema.dart';
final recipe = S.object({
'title': S.string().nonEmpty().describe('Recipe name'),
'servings': S.integer().min(1),
'difficulty': S.enumeration(['easy', 'medium', 'hard']),
'tags': S.list(S.string()).withDefault([]),
'notes': S.string().optional(),
});
// 1. Send the schema to your LLM as a tool definition:
final toolParameters = recipe.toJsonSchema();
// 2. Validate what comes back — even if it's wrapped in ``` fences or prose:
final result = recipe.safeParseText(modelResponse);
switch (result) {
case SchemaSuccess(value: final v):
print(v['title']);
case SchemaFailure(issues: _) && final failure:
// 3. Feed precise errors back to the model for a retry:
print(failure.toPromptString());
// - $.servings: Missing required field
// - $.difficulty: Expected one of: easy, medium, hard; got "expert"
}
The repair loop #
LLMs don't always get JSON right on the first try. toPromptString() turns
validation failures into a message the model can act on:
Future<Map<String, Object?>> generate(String prompt) async {
var request = prompt;
for (var attempt = 0; attempt < 3; attempt++) {
final response = await callModel(request);
final result = recipe.safeParseText(response);
if (result case SchemaSuccess(value: final v)) return v;
request = '$prompt\n\nYour previous reply had problems:\n'
'${(result as SchemaFailure).toPromptString()}\n'
'Reply with corrected JSON only.';
}
throw StateError('Model failed to produce valid JSON.');
}
Typed Dart objects #
transform maps validated data into your own types — records or classes:
final userSchema = S.object({
'name': S.string(),
'age': S.integer().min(0),
}).transform((m) => (name: m['name'] as String, age: m['age'] as int));
final user = userSchema.parseText(response); // (name: ..., age: ...)
Schema reference #
| Builder | Matches | Chainable constraints |
|---|---|---|
S.string() |
strings | min, max, nonEmpty, pattern, email |
S.integer() |
integers (accepts 5.0) |
min, max, positive |
S.number() |
any number → double |
min, max |
S.boolean() |
booleans | |
S.literal(v) |
exactly v |
|
S.enumeration([...]) |
one of fixed strings | caseInsensitive: |
S.enumOf(MyEnum.values) |
Dart enum by name | caseInsensitive: |
S.list(item) |
arrays | min, max, nonEmpty |
S.object({...}) |
objects (unknown keys stripped) | |
S.map(value) |
dictionaries | |
S.anyOf([...]) |
first matching option | |
S.any() |
anything |
Every schema also supports:
.nullable()— acceptnulltoo (emitted as"type": ["...", "null"]).optional()— object field may be omitted.withDefault(v)— value used when missing ornull.describe('...')— description included intoJsonSchema(); this is how you tell the model what a field means.refine(predicate, message: ...)— custom checks.transform(fn)— map to your own types
And three ways to parse:
schema.safeParse(decodedJson)→SchemaResult<T>(never throws)schema.parse(decodedJson)→T(throwsSchemaValidationException)schema.safeParseText(rawText)/schema.parseText(rawText)— same, but extracts JSON from raw model output first
The standalone extractJson(text) is also exported if you only need the
lenient extraction step.
LLM-friendly by design #
- Whole-valued doubles (
5.0) are accepted byS.integer()— models emit them constantly. - Unknown object keys are stripped instead of failing.
S.enumeration(..., caseInsensitive: true)normalizes"HIGH"→"high".- All issues are collected in one pass (not fail-fast), so a repair prompt fixes everything at once.
additionalProperties: falseis emitted for objects, as strict structured-output modes require.
Note for OpenAI strict mode: strict structured outputs require every property to be
required. Prefer.nullable()over.optional()there.
Why not codegen? #
json_serializable and friends are great for stable app models. Schemas
for LLM I/O are different: they change with every prompt iteration, they
need to ship a JSON Schema to the model, and they must survive sloppy
output. A runtime schema you can declare inline — with zero build step —
fits that loop better. (Dart macros, which would have solved this at the
language level, were discontinued in 2025.)
License #
MIT