# A public const constructor is the back door around your factory's validation

> Source: <https://dev.to/yusufihsangorgel/a-public-const-constructor-is-the-back-door-around-your-factorys-validation-2hh8>
> Published: 2026-07-23 20:15:24+00:00

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.

`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']);
```

`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.

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: '(');
```

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']);
```

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.

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:

``` js
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);
}
```

`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.

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'^[^@]+@[^@]+$');
```

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.

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.

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.

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);
  }
}
```

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.
