An LLM decided to call one of your tools, and the arguments came back as JSON. It's almost the JSON the struct wants, but not quite. The model sent passengers as the string "2", left max_price out entirely, added a _trace_id field nobody declared, and put "coach" in a cabin field whose four known values don't include it. These need to become a typed Rust struct, and the library that does it here is Laminate, a serde-native crate built for exactly this almost-right JSON. The right baseline to measure it against is plain serde, though, so start there.
serde is strict, and that strictness is usually the right call. Here it's friction. A string where the struct wants a u8 is a hard error. A missing field needs a #[serde(default)]. An unexpected field gets dropped quietly, so its arrival goes unnoticed. The invented enum value fails the whole parse. You can get there, but you get there by hand, with a custom deserializer for every field the model might stringify, and after all that work serde still says nothing about what it changed.
Laminate fills exactly that gap. The struct stays as it is, a few attributes go on, and one call shapes the JSON that arrived into the types it's supposed to be: It coerces values, fills defaults, captures the fields nobody named, and hands back a per-field record of every change. This post shapes one realistic tool call, end to end, and shows that record.
The tool, and the arguments that actually arrive
Say the model called a flight-search tool. The arguments, however they were received (a companion post covers reading a tool call uniformly across providers), land as this object:
Show the code
{
"origin": "SFO",
"destination": "JFK",
"depart_date": "2026-07-02",
"passengers": "2",
"nonstop": "true",
"cabin": "coach",
"_trace_id": "req_abc123"
}
Five things are off from a strict reading. passengers is a string holding a number. nonstop is a string holding a boolean. max_price is absent. _trace_id is a field the struct never declared. And cabin holds a value outside the expected set. None of this is malformed JSON. The bytes parse fine. The gap is between the shape that arrived and the shape the types describe.
The serde struct you'd write today, and what it can't tell you
Here's the serde baseline. With serde alone, the struct needs help at three of those fields, and the arguments string needs parsing first:
Show the code
#[derive(Deserialize)]
struct SearchFlights {
origin: String,
destination: String,
depart_date: String,
#[serde(deserialize_with = "string_or_int")]
passengers: u8,
#[serde(deserialize_with = "string_or_bool")]
nonstop: bool,
cabin: String,
#[serde(default)]
max_price: Option<u32>,
// _trace_id is dropped silently. Its arrival goes unrecorded.
}
Each deserialize_with is a small visitor written by hand. Here's the one for passengers, and nonstop needs its twin:
Show the code
fn string_or_int<'de, D: Deserializer<'de>>(d: D) -> Result<u8, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = u8;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a u8, or a string holding one")
}
fn visit_u64<E>(self, v: u64) -> Result<u8, E> { Ok(v as u8) }
fn visit_str<E: de::Error>(self, v: &str) -> Result<u8, E> {
v.parse().map_err(de::Error::custom)
}
}
d.deserialize_any(V)
}
That's roughly fifteen lines per coerced field, and it grows with every tool. The cabin value is worse: #[serde(other)] exists for unknown enum variants, but it's unit-only, so it can flag that a value was unrecognized while throwing the value itself away. And here's the part that matters most when you don't control the source: After all of this, serde has quietly turned "2" into 2, quietly filled max_price, and quietly discarded _trace_id. Nothing in the result says any of it happened. When a provider starts sending a new field, or a value that needs migrating, it shows up as a downstream bug, not as a note on the parse.
The same thing, with a derive
Laminate keeps the struct and replaces the hand-written visitors with attributes:
Show the code
use laminate::Laminate;
use std::collections::HashMap;
#[derive(Laminate)]
struct SearchFlights {
origin: String,
destination: String,
depart_date: String,
#[laminate(coerce)] passengers: u8, // "2" -> 2
#[laminate(coerce)] nonstop: bool, // "true" -> true
cabin: String, // "coach", kept as-is
#[laminate(default)] max_price: Option<u32>, // absent -> None
#[laminate(overflow)] extra: HashMap<String, Value>, // captures _trace_id
}
let (flights, diagnostics) = SearchFlights::from_flex_value(args.raw())?;
That call shapes the struct, and it lands: passengers == 2, nonstop == true, cabin == "coach", max_price == None, and extra holds _trace_id. The attributes do declaratively what the visitors did by hand: coerce pulls a stringified scalar into the field's type, default fills a missing field, and overflow captures every field nobody named into a map instead of dropping it. The difference that doesn't show up in the values is the second return: diagnostics.
Knowing exactly what the parser changed
diagnostics is the thing serde structurally can't hand back. It's a record, one entry per adjustment, of what the parser did to make the data fit:
Show the code
[info] at 'passengers': coerced string → u8
[info] at 'nonstop': coerced string → bool
[info] at 'max_price': defaulted field 'max_price' (null)
[info] at '_trace_id': preserved unknown field '_trace_id' in overflow
There are four entries, each with a path, a description, and a risk level. They're all info here; coercions that can lose data carry warning or risky, and some entries add a suggestion for tightening the source. You can log it, count it, or branch on it. The field that changed type still landed. The field that went missing still has a value. The field that appeared is still here, in extra. And there's a written account of every liberty the parser took to get there, which is the difference between shaping data you don't control and guessing about it.
Unknown enum values, without losing them
cabin arrived as the raw string "coach", and a String field holds it without complaint. When it needs to be a typed value that still survives a model inventing a class, a Laminate enum does that, shaped at its own level:
Show the code
#[derive(Laminate)]
enum Cabin {
Economy,
Premium,
Business,
First,
#[laminate(unknown)] Other(String),
}
let (cabin, diagnostics) = Cabin::from_flex_value(&json!("coach"))?;
// cabin == Cabin::Other("coach")
// [warning] at '(root)': coerced string → Cabin::Other (unrecognized variant)
"coach" becomes Cabin::Other("coach"): Not an error, and not the discarded value that #[serde(other)] would leave behind. The value is kept, and the warning flags that a model sent something outside the set, which is worth knowing, because models invent enum values all the time. The enum shapes at the top level here rather than as a struct field on purpose: Laminate hands struct fields to serde for their final typed step (it's built to complement serde, not replace it), so an enum's open fallback lives where the enum itself is the thing being shaped.
Lenient in development, strict in production
The same struct can be looser or tighter without a second definition. The entry point sets the strictness:
Show the code
// development: coerce, default, capture, and record every change
let (flights, diagnostics) = SearchFlights::from_flex_value(args.raw())?;
// production: refuse to coerce, and fail loudly on a surprise
let strict = SearchFlights::shape_strict(args.raw());
// Err: a coercion was needed (passengers arrived as a string), so strict mode declined
shape_strict returns an error on the same input that from_flex_value shapes happily, because a value had to be coerced to fit. That's the dial: Run lenient where the goal is to see exactly what a provider is sending, and strict where a surprise should stop the line rather than slip through as a quiet coercion. One call changes, not the types. Appendix C lays out the three modes and what each one switches.
Where it fits
Laminate is for one specific case, and it's a common one: The response is real JSON, its structure is close to what the types describe, and the gap is types, missing or extra fields, and the occasional value outside the set. It shapes that, and it reports what it changed.
The neighboring tools own their own jobs. When you control both ends of the wire, serde's strictness is exactly right, and there's nothing to absorb. When the model's output has to be valid the moment it's produced, constrained decoding gives that at generation time. Laminate works one step later, on the arguments that already arrived, turning them into the struct that already exists, with a record of every adjustment. cargo add laminate with the derive feature, put the attributes on the struct you were going to write anyway, and the tool arguments stop being a field-by-field chore.
FAQ
Does serde coerce strings to numbers?
No. serde is strict by default: A JSON string where the field expects a u8 is a deserialization error. You can opt into coercion per field with a deserialize_with visitor, or shape the value first with a coercion library. Laminate's #[laminate(coerce)] does it per field and records that it happened.
How do I parse OpenAI function-call arguments in Rust?
OpenAI sends function.arguments as a JSON string, not an object, so that string gets parsed into JSON first, then deserialized into the target type. Laminate's provider adapter does the string-to-object step, and the derive shapes the result.
How do I handle unknown enum variants in serde?
serde's #[serde(other)] catches an unknown variant, but it's unit-only, so it can't keep the original value. When the value matters (for logging, migration, or pass-through), a Laminate enum with #[laminate(unknown)] Other(String) captures it and records a warning.
How is this different from serde_json::Value?
Value is untyped: Every access navigates and matches it by hand. Laminate shapes a Value into a real struct in one call, coercing and recording as it goes, so the rest of the code works with types, not with Value.
Appendix A: How often does this actually recover?
A claim about absorbing messy arguments should be measured, so here's the measurement and how it was run.
What was measured. Given a valid tool call's arguments, rendered in a malformed but realistic way, how often does Laminate recover the accepted argument value? This isolates the parser; it isn't an end-to-end model-accuracy number.
Substrate. The Berkeley Function-Calling Leaderboard (BFCL), a public dataset of function schemas paired with ground-truth accepted values, vendored at a pinned commit (Apache-2.0). Eight categories, joined to ground truth: 3,176 distinct tool calls.
Method. For each call, build the argument object from BFCL's accepted value per parameter, render it under each malformation class, run it through FlexValue::from_llm_response and extract per the schema type, and count a recovery when the result is in BFCL's accepted-value list. A call recovers when all of its arguments do.
| Class | Recovery | What it tests |
|---|---|---|
| clean | 3176/3176 (100%) | baseline |
| code-fenced | 3176/3176 (100%) | args wrapped in a Markdown code block |
| prose-wrapped | 3176/3176 (100%) | args embedded in surrounding text |
| stringified scalars | 3169/3176 (~100%) | numbers and booleans sent as strings |
| whitespace-padded | 3176/3176 (100%) | leading and trailing whitespace |
| extra field | 3176/3176 (100%) | an unexpected field added |
| trailing comma | 0/3176 (0%) | JSON-syntax breakage |
| single quotes | 0/3176 (0%) | JSON-syntax breakage |
| truncated | 0/3176 (0%) | cut off mid-structure |
| unquoted keys | 0/3176 (0%) | JSON-syntax breakage |
| Python literals | 0/290 (0%) | True/False/None instead of JSON |
Per category (the recoverable column sums the six classes Laminate shapes; syntactic sums the five out-of-scope classes):
| Category | Calls | Recoverable | Syntactic (out of scope) |
|---|---|---|---|
simple_python |
400 | 2400/2400 (100%) | 0/1638 (0%) |
simple_java |
100 | 600/600 (100%) | 0/417 (0%) |
simple_javascript |
50 | 299/300 (100%) | 0/206 (0%) |
multiple |
200 | 1200/1200 (100%) | 0/817 (0%) |
parallel |
538 | 3228/3228 (100%) | 0/2200 (0%) |
live_simple |
257 | 1542/1542 (100%) | 0/1042 (0%) |
live_multiple |
1024 | 6138/6144 (100%) | 0/4211 (0%) |
parallel_multiple |
607 | 3642/3642 (100%) | 0/2463 (0%) |
| all | 3176 | 19049/19056 (100%) | 0/12994 (0%) |
Reading the numbers straight. Coercion, defaults, extra fields, and payload wrapping recover at 100% in every category. The four syntactic-repair classes and the Python-literal class recover at 0%, by design: Value coercion and payload extraction are Laminate's job, and JSON-syntax repair is out of scope. The seven stringified-scalar misses (one in simple_javascript, six in live_multiple) trace to BFCL entries whose schema labels a boolean parameter as a string; the parser coerces toward the declared type and correctly declines to turn true into a string. That's a data-labeling artifact in the fixtures, left in rather than papered over.
Appendix B: The derive attributes
#[derive(Laminate)] shapes a serde_json::Value into a struct, applying a rule per field and recording what it did.
| Attribute | Effect |
|---|---|
#[laminate(coerce)] |
Coerce the value toward the field's type ("5" to 5, "true" to true). |
#[laminate(default)] |
Use Default::default() when the field is missing or null, with a Defaulted diagnostic. |
#[laminate(overflow)] |
Capture every unnamed field into a map, so added fields are kept, not dropped. |
#[laminate(rename = "k")] |
Read the field from JSON key k. |
#[laminate(parse_json_string)] |
If the value is a string, parse it as JSON first. |
#[laminate(flatten)] |
Merge a nested object's fields into the parent. |
#[laminate(skip)] |
Don't read from input; use Default. |
Enums get the same derive: A unit-variant enum matches by name (or rename), and a single #[laminate(unknown)] newtype variant captures an unrecognized value instead of erroring. The entry points it generates:
Show the code
// Value in; value plus diagnostics out.
fn from_flex_value(value: &serde_json::Value) -> Result<(Self, Vec<Diagnostic>)>
fn from_json(json: &str) -> Result<(Self, Vec<Diagnostic>)>
fn from_llm_response(text: &str) -> Result<(Self, Vec<Diagnostic>)>
// Mode-typed: the residual type encodes how strict the shaping was.
fn shape_lenient(value: &serde_json::Value) -> Result<LaminateResult<Self, Lenient>>
fn shape_absorbing(value: &serde_json::Value) -> Result<LaminateResult<Self, Absorbing>>
fn shape_strict(value: &serde_json::Value) -> Result<Self>
from_json parses, then calls from_flex_value. from_llm_response pulls the JSON payload out of fenced or prose-wrapped text first, then shapes it.
Appendix C: Modes, diagnostics, and coercion
Modes. A mode sets four switches at once. It's a type parameter, so the choice is visible in the type and checked at compile time.
| Lenient | Absorbing | Strict | |
|---|---|---|---|
| Unknown fields | dropped | preserved in overflow | error |
| Coercion level | BestEffort |
SafeWidening |
Exact |
| Missing fields | defaulted | error | error |
| On error | collect all | collect all | fail fast |
| Residual type | () |
Overflow (HashMap) |
Infallible |
The residual type is the quiet part doing real work. Strict carries Infallible, a type with no values, so a strict result is a compile-time proof that nothing was left over. A DynamicMode enum parses from the strings lenient, absorbing, and strict for the case where the mode comes from config.
Coercion levels. Each mode picks a default level, an ordered scale of how far a value is pushed toward the target type.
| Level | What it does |
|---|---|
Exact |
no coercion; types must match |
SafeWidening |
safe numeric widening only (int to float) |
StringCoercion |
parse strings to targets ("5" to 5, "true" to true) |
BestEffort |
the above, plus null to default, stringified JSON, single value to array, radix-prefixed and comma-grouped numbers |
Diagnostics. Every coercion, default, drop, and preservation is recorded as a Diagnostic { path, kind, risk, suggestion }.
Show the code
pub enum DiagnosticKind {
Coerced { from: String, to: String }, // "5" to 5
Defaulted { field: String, value: String }, // missing field filled
Dropped { field: String }, // unknown field dropped (lenient)
Preserved { field: String }, // unknown field kept (absorbing)
ErrorDefaulted { field: String, error: String }, // field failed, default used
Overridden { from_type: String, to_type: String },
}
pub enum RiskLevel { Info, Warning, Risky } // ordered: Info < Warning < Risky
Diagnostics route through a DiagnosticSink: Collect into a Vec, print to stderr, forward only at or above a risk threshold, or drop them. Gating a production path on "no Risky diagnostics" is a few lines against that trait.