You put your validation in a factory. Schema.string(pattern: ...) checks that the pattern is a real regex before it hands you an object. Schema.enumeration(values) refuses an empty list and copies the one you pass in. The factory is the front door, and it has a bouncer.

Then someone calls the constructor instead.

If the concrete class has a public constructor sitting next to that factory, it is a second door into the same object, and that door has no bouncer. Everything the factory checked, the constructor skips. This is a general Dart trap, and I hit it in my own package, instructor_dart. The interesting part is why the obvious fix does not work: the constructor is const, and const is exactly what stops it from defending itself.

The setup

instructor_dart takes a plain-Dart schema and a prompt and returns a validated, correctly-typed Dart object from an LLM (OpenAI, Anthropic, Gemini). You describe the shape you want with a small schema API:

import 'package:instructor_dart/instructor_dart.dart';

final email = Schema.string(pattern: r'^[^@]+@[^@]+$');
final priority = Schema.enumeration(['low', 'medium', 'high']);

Enter fullscreen mode Exit fullscreen mode

Schema is a sealed base type with static factory methods: Schema.string(), Schema.integer(), Schema.number(), Schema.boolean(), Schema.enumeration(), Schema.list(), and Schema.object(). Each one returns a concrete type: StringSchema, IntegerSchema, EnumSchema, and so on.

Those concrete types are where the problem lived.

Two doors, one object

Before 0.4.0, the concrete classes had public const constructors. So you could build a StringSchema two ways:

  • Schema.string(pattern: '(') goes through the factory, which validates.
  • StringSchema(pattern: '(') goes straight to the constructor, which does not.

Both gave you a StringSchema. Only one of them checked anything. Two of the factory invariants were real, and both were skippable through the raw constructor.

The regex is validated eagerly. Schema.string(pattern: ...) runs RegExp(pattern) at construction time. If you pass a broken pattern, you get a FormatException right there, at the line where you wrote the mistake:

// Schema.string runs RegExp(pattern) and throws FormatException on a bad pattern:
final bad = Schema.string(pattern: '(');

Enter fullscreen mode Exit fullscreen mode

Call the raw StringSchema(pattern: '(') constructor and that check never runs. The broken pattern gets stored and sits there until something tries to match against it, which is usually far away from the typo, in a stack trace that points at the matcher instead of at you.

The enum set cannot be empty, and it gets copied. Schema.enumeration(values) throws ArgumentError if values is empty, and it copies the list with List.unmodifiable. So the allowed set is never empty, and nobody can mutate it after the fact:

final priority = Schema.enumeration(['low', 'medium', 'high']);

Enter fullscreen mode Exit fullscreen mode

The raw EnumSchema(const []) constructor skipped both. You could build an enum that matches nothing, or pass in a list you keep a reference to and change later, out from under the schema.

"Just make the constructor validate itself"

This is the obvious idea, and in Dart it does not work, because these constructors are const.

A const constructor is restricted. It can assign to fields and it can run assert statements in its initializer list. That is the whole menu:

class Port {
  final int value;
  // A const constructor may assert. That is as far as it goes.
  const Port(this.value) : assert(value >= 1 && value <= 65535);
}

Enter fullscreen mode Exit fullscreen mode

assert has two problems for real validation. It only runs in debug mode, so in a release build it is gone. And it takes a boolean condition, so it cannot express "parse this string as a regular expression and throw a FormatException if it fails." There is no way to call RegExp(pattern) from inside a const constructor, and no way to throw an ArgumentError conditionally the way an ordinary method can.

So the real checks have to live somewhere that can run real code: a factory, or a static method. That is the actual reason these packages reach for factories in the first place. The factory is the only place a check can live, and a public const constructor sits right beside it as a way around it.

The fix in 0.4.0

Make the concrete constructor library-private. Each concrete class now has a private named constructor, and the factory routes through it. In shape:

  • StringSchema has const StringSchema._(...).
  • Schema.string(...) does its regex check, then calls StringSchema._(...).

From a caller's side, the change is small and total. The factory still works exactly as before:

// Still the way to do it:
final email = Schema.string(pattern: r'^[^@]+@[^@]+$');

Enter fullscreen mode Exit fullscreen mode

Writing StringSchema(pattern: r'^[^@]+@[^@]+$') is now an analyzer error: the class has no accessible unnamed constructor. There is no second door to walk through.

The concrete classes stay exported as types, and that part matters. You still get StringSchema and EnumSchema as return types, you can still switch over the sealed Schema hierarchy and match on them, and you can still read their fields. Closing the constructor closes construction only; the type is still yours to use.

One more thing fell out of this. The factories do not take an isOptional argument, so once the raw constructors were closed, the only way left to mark a field optional is the .optional() method that was the intended one. Removing the public constructor removed the shortcut around that too.

Honest scope: this was mostly preventive

I want to be exact about what this changed, because it did not fix five broken validators.

Of the seven factories, only two enforced an invariant that the raw constructor skipped: string with its regex check, and enumeration with its non-empty-and-copy rule. Those two were real holes, and closing the constructor closed them.

The other five (integer, number, boolean, list, object) forwarded their arguments and validated nothing. For those, there was no bug to fix. Closing their constructors is preventive: it means that the day someone adds a check to Schema.integer(), there is no public IntegerSchema(...) sitting next to it to route around the check. The single construction path is set up before it carries anything worth protecting.

So the count is small: two real checks locked, and five constructors closed before they ever guard anything, with all seven now built through one path.

Why this had to happen before 1.0.0

Removing a public constructor is a breaking change. Any code that wrote StringSchema(...) stops compiling.

Once a package puts out a stable 1.0.0, you cannot take a public constructor away without going to 2.0.0. instructor_dart is young, so the cost of the break right now rounds to zero. After 1.0.0 the same cleanup costs a major version and a migration for everyone who depends on you. This was the cheap moment, so I took it.

If a factory validates, close the constructor

The specific bug was mine, but the shape is everywhere: any value type with a validating factory and a public constructor has a back door. If you write these in Dart, the mechanics are worth keeping in your head. Here is the Port from earlier with the door closed:

class Port {
  final int value;

  // The only real constructor, and it is private.
  const Port._(this.value);

  // The public door, and it can run whatever checks it wants.
  factory Port(int value) {
    if (value < 1 || value > 65535) {
      throw ArgumentError.value(value, 'value', 'must be 1..65535');
    }
    return Port._(value);
  }
}

Enter fullscreen mode Exit fullscreen mode

Give the class a private named constructor (ClassName._), route the factory through it, and if the type is part of a sealed hierarchy, keep the class exported so it still works as a type in returns and switch arms. The const constructor cannot carry the validation itself, because const constructors can only assign and assert. The factory is where the checks live, and a public constructor next to it is a way around them.

Go look at your own value types. If the validation is in the factory and the constructor is public, you have two doors, and callers will find the one without the bouncer.