llm_schema 0.1.0 copy "llm_schema: ^0.1.0" to clipboard
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:

  1. Typed validation of AI-generated JSON, with path-aware errors ($.items[2].price: Expected number, got string).
  2. JSON Schema output (toJsonSchema()) for tool/function definitions and structured-output APIs (Claude, OpenAI, Gemini).
  3. 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() — accept null too (emitted as "type": ["...", "null"])
  • .optional() — object field may be omitted
  • .withDefault(v) — value used when missing or null
  • .describe('...') — description included in toJsonSchema(); 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 (throws SchemaValidationException)
  • 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 by S.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: false is 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

#

1
likes
150
points
103
downloads

Documentation

API reference

Publisher

verified publisherpauloriveiro.com

Weekly Downloads

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, no codegen.

Repository (GitHub)
View/report issues

Topics

#json #validation #schema #llm #ai

License

MIT (license)

More

Packages that depend on llm_schema