DEV Community

Yusuf İhsan Görgel
Yusuf İhsan Görgel

Posted on

Your schema said number, the model returned 42, and the cast threw

instructor_dart gives you typed, validated structured output from an LLM. You describe the shape you want as a schema in plain Dart, the package asks the model for JSON, validates it against the schema, retries with the validation errors if it does not fit, and only then hands the JSON to your fromJson. The promise is that by the time your code runs, every field is present and correctly typed.

It kept that promise, except for one kind of value, and the failure was a cast that threw after validation had already said the data was fine.

The same value 42 read two ways: reading the JSON value gives int 42 so a cast to double throws, while reading the schema normalizes it to double 42.0 and the cast holds

Here is a schema with two number fields:

final reading = await instructor.extract(
  messages: const [
    Message.user('The sensor read 42 degrees at latitude 51.5.'),
  ],
  schema: Schema.object({
    'temperature': Schema.number(),
    'latitude': Schema.number(),
  }),
  fromJson: Reading.fromJson,
);

class Reading {
  Reading.fromJson(Map<String, Object?> json)
      : temperature = json['temperature'] as double,
        latitude = json['latitude'] as double;

  final double temperature;
  final double latitude;
}
Enter fullscreen mode Exit fullscreen mode

latitude comes back as 51.5 and the cast to double is fine. temperature comes back as 42, a whole number, and json['temperature'] as double throws type 'int' is not a subtype of type 'double'. Validation had passed: 42 is a perfectly good number, so the schema was satisfied and the retry loop never fired. The type error happens later, in your own fromJson, on a value the library told you was valid.

Why a number is sometimes an int

JSON has one number type. Dart has two, int and double, and they are not interchangeable for a cast. jsonDecode has to pick one for each number, and it picks by looking at the text:

import 'dart:convert';

void main() {
  print(jsonDecode('42').runtimeType);    // int
  print(jsonDecode('42.0').runtimeType);  // double on the VM, int on the web
}
Enter fullscreen mode Exit fullscreen mode

So 42 decodes to int and 42.5 decodes to double. A field you think of as a double is a double only while its values happen to have a fractional part. The moment the model writes a round number, that field is an int, and every as double in your fromJson is a landmine waiting for round data.

The web makes it worse. Compiled to JavaScript, Dart has a single number type, so jsonDecode('42.0') gives you an int there while the VM gives you a double. The same code, the same JSON, a different runtime type depending on where it runs.

The fix is to normalize to the type the schema promised

The schema already carries the answer. A field declared Schema.number() is a double, whatever the JSON text looked like. A field declared Schema.integer() is an int. The type is not something to guess from the value; it is something the schema states.

instructor_dart 0.3.0 normalizes each value to the Dart type its schema node promises, once, right after validation passes and before fromJson runs. A number field is turned into a double, an integer field into an int, and object and list schemas apply the same rule to their children. After that, the code above works: temperature is 42.0, a double, and the cast holds.

// instructor_dart 0.3.0, after validation:
//   a Schema.number()  field is always a double
//   a Schema.integer() field is always an int
// on the VM and on the web, whatever the model wrote.
Enter fullscreen mode Exit fullscreen mode

The version before this tried to help and made it worse. It collapsed every integral double to an int across the whole document, to paper over the VM-versus-web difference. That meant a number field that arrived as 42.0 was turned into 42, an int, so as double threw even for data that decoded correctly in the first place. The mistake was doing it globally. jsonDecode cannot know that 42 belongs to a double field, but the schema can, so the coercion has to be per-field, keyed on what each node declares, not a blanket pass over every number in the tree.

The rule I would carry to any typed-output layer over JSON: the wire type and the type you promised can disagree on whole numbers, and the schema is the only thing that knows which one is right. Normalize against the schema, not against the value, and do it before the typed constructor ever sees the data.

Top comments (0)