Skip to content

What gets generated

For each class annotated with @luthor, the generator writes five items into the part file: a generated schema, a validate function, a validateSelf extension, schema keys, and error keys. This page shows all five for one model. The generated code is copied from a real build_runner run on the model below. The pages that follow use the same User and Address.

lib/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:luthor/luthor.dart';
part 'user.freezed.dart';
part 'user.g.dart';
enum Role { admin, member }
@luthor
@freezed
abstract class User with _$User {
const factory User({
@HasMin(2) required String name,
@IsEmail() @JsonKey(name: 'email_address') required String email,
@HasMin(18) int? age,
@Default(Role.member) Role role,
required Address address,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
@luthor
@freezed
abstract class Address with _$Address {
const factory Address({
required String street,
required String city,
@MatchRegex(r'^\d{5}$') required String postcode,
}) = _Address;
factory Address.fromJson(Map<String, dynamic> json) => _$AddressFromJson(json);
}

$UserSchema is a final SchemaValidator. Each field’s validator is derived from the parameter type and its annotations; Supported field types lists the mapping. The enum becomes l.oneOf() of its names, the default makes role optional, and the nested model is referenced through forwardRef(). The schema is named after the model with withName.

lib/user.g.dart
final SchemaValidator $UserSchema = l
.schema({
UserSchemaKeys.name: l.string().min(2).required(),
UserSchemaKeys.email: l.string().email().required(),
UserSchemaKeys.age: l.int().min(18),
UserSchemaKeys.role: l.oneOf(["admin", "member"]),
UserSchemaKeys.address: forwardRef(() => $AddressSchema.required()),
})
.withName("User");
lib/user.g.dart
final SchemaValidator $AddressSchema = l
.schema({
AddressSchemaKeys.street: l.string().required(),
AddressSchemaKeys.city: l.string().required(),
AddressSchemaKeys.postcode: l
.string()
.regex(RegExp(r'^\d{5}$'))
.required(),
})
.withName("Address");

$UserValidate accepts any input and runs validateSchema with the model’s fromJson, so success carries a User.

lib/user.g.dart
ValidationResult<User> $UserValidate(Object? json) =>
$UserSchema.validateSchema(json, fromJson: User.fromJson);

For a dart_mappable model the function uses UserMapper.fromMap instead.

$UserValidate(input)+1-1
  1. valid: {'name': 'Ada', 'email_address': '[email protected]', 'age': 36, 'role': 'admin', 'address': {...}}
  2. invalid: {'name': 'A', 'email_address': 'ada', 'age': 12, 'role': 'owner', 'address': {..., 'postcode': '12'}}name: name must be at least 2 characters longemail_address: email_address must be a valid email addressage: age must be greater than or equal to 18role: role must be one of: admin, memberaddress.postcode: postcode must match the pattern ^\d{5}$

Input that is not a map at a nested field reports with the field’s name:

{address: [address must be a map]}

The extension serialises an instance with toJson(), converts any nested objects left as instances, and validates the result. It is how you check a model you built in code rather than parsed from input. It is generated only for models with a toJson() or a dart_mappable toMap().

lib/user.g.dart
extension UserValidationExtension on User {
ValidationResult<User> validateSelf() =>
$UserValidate(_$luthorJsonMap(toJson()));
}
final user = User(
name: 'A',
email: 'ada',
address: const Address(street: 's', city: 'c', postcode: '12345'),
);
print(user.validateSelf());
ValidationFailure(errors: {name: [name must be at least 2 characters long], email_address: [email_address must be a valid email address]})

UserSchemaKeys is a record. Each field is named after the Dart parameter and holds the key as it appears in the input map, which is the @JsonKey name when there is one. The generated schema uses these keys.

lib/user.g.dart
const UserSchemaKeys = (
name: "name",
email: "email_address",
age: "age",
role: "role",
address: "address",
);

UserErrorKeys has the same fields, but each holds an error path for getError. A field whose type is another model becomes a nested record whose paths carry the prefix, with $key for the field’s own path.

lib/user.g.dart
const UserErrorKeys = (
name: "name",
email: "email_address",
age: "age",
role: "role",
address: (
$key: "address",
street: "address.street",
city: "address.city",
postcode: "address.postcode",
),
);
print(result.getError(UserErrorKeys.email));
print(result.getError(UserErrorKeys.address.postcode));
print(result.getError(UserErrorKeys.address.$key));
print(UserSchemaKeys.email);
print(UserErrorKeys.address.$key);
email_address must be a valid email address
postcode must match the pattern ^\d{5}$
null
email_address
address

Schema keys and error keys goes into both records.

A generated schema strips keys it does not declare before fromJson runs, like every 1.0 schema, so input with extra keys validates and the extras never reach the serializer:

$UserValidate({..., 'nickname': 'A', 'address': {..., 'country': 'FR'}}).isValid
true

Every key the serializer reads has to be a schema field, and the generator makes it one: for a @JsonSerializable class, public settable fields that are not constructor parameters are schema fields too. When the serializer may read keys the generator cannot name, the schema gets .passthrough() instead: a field with @JsonKey(readValue:), a private field with @JsonKey(includeFromJson: true), or a @MappableClass(hook:).

When a field’s type is a class without @luthor that has a fromJson or @MappableClass and a constructor with named parameters, the generator emits a private schema for it, _$ClassSchema, once per library. It gets no public keys, validate function or extension. Annotate the class with @luthor to get the public API.

The schema follows the constructor the serializer uses: the one named in @JsonSerializable(constructor:), the @MappableConstructor, the unnamed constructor, or else the first public constructor with parameters.