Code Generation
Luthor provides powerful code generation capabilities that enhance type safety and developer experience when working with validation schemas. The code generator creates type-safe constants and validation functions from your annotated classes.
Setup
First, add the necessary development dependencies:
dart pub add dev:build_runner dev:luthor_generatorThen add the @luthor annotation to your classes and run code generation:
dart run build_runner buildGenerated Code Overview
For each @luthor annotated class, the generator creates:
- SchemaKeys - Type-safe constants for defining schemas
- ErrorKeys - Type-safe constants for accessing validation errors
- Schema - The actual validation schema using SchemaKeys
- Validation Function - A function to validate and optionally deserialize data
How Generated Keys Work
Both SchemaKeys and ErrorKeys use the same structure: Dart field names as record keys and JSON field names as record values. This pattern allows you to:
- Use type-safe Dart field names for code access (autocomplete, refactoring)
- Have record values that match the JSON structure (respecting
@JsonKeyannotations)
For example, with @JsonKey(name: 'email_address') required String email:
- Record field name:
email(Dart field name - for type-safe access) - Record value:
"email_address"(JSON field name - for schema/error lookup)
SchemaKeys - Type-Safe Schema Definition
SchemaKeys provide compile-time safety when defining your validation schemas by generating constants that match your class fields.
@luthor@freezedabstract class User with _$User { const factory User({ required String name, required String email, required int age, }) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);}Generated SchemaKeys
// Generated code - Dart field names as keys, JSON field names as valuesconst UserSchemaKeys = ( name: "name", email: "email", age: "age",);Usage in Schema Definition
The generated schema automatically uses these keys:
// Generated schema using SchemaKeysValidator $UserSchema = l.withName('User').schema({ UserSchemaKeys.name: l.string().required(), UserSchemaKeys.email: l.string().email().required(), UserSchemaKeys.age: l.int().required(),});Benefits of SchemaKeys
- Refactoring Safety - Renaming fields updates keys automatically
- IDE Support - Autocomplete and go-to-definition
- Typo Prevention - Compile-time errors for invalid field names
ErrorKeys - Type-Safe Error Access
ErrorKeys provide type-safe access to validation errors with support for nested fields using dot notation.
Generated ErrorKeys
For nested classes, ErrorKeys use nested record structures with dot notation:
// For a simple class - same structure as SchemaKeysconst UserErrorKeys = ( name: "name", email: "email", age: "age",);
// For nested classes - uses nested records with dot notationconst ProfileErrorKeys = ( id: "id", user: ( name: "user.name", email: "user.email", age: "user.age", ), settings: ( theme: "settings.theme", notifications: "settings.notifications", ),);Using ErrorKeys with getError()
Instead of using error-prone strings:
// ❌ Error-prone string literalsfinal emailError = result.getError('user.email');final themeError = result.getError('settings.theme');Use type-safe ErrorKeys:
// ✅ Type-safe with autocompletefinal emailError = result.getError(ProfileErrorKeys.user.email);final themeError = result.getError(ProfileErrorKeys.settings.theme);JsonKey Support
The generator respects @JsonKey annotations for proper field mapping:
@luthor@freezedabstract class ApiUser with _$ApiUser { const factory ApiUser({ required String name, @JsonKey(name: 'email_address') required String email, @JsonKey(name: 'user_age') required int age, }) = _ApiUser;
factory ApiUser.fromJson(Map<String, dynamic> json) => _$ApiUserFromJson(json);}Generated Keys with JsonKey Mapping
Both SchemaKeys and ErrorKeys follow the same pattern - Dart field names as record keys, JSON field names as record values:
// SchemaKeys - Dart field names as keys, JSON field names as valuesconst ApiUserSchemaKeys = ( name: "name", email: "email_address", // Record key: email (Dart) -> Value: email_address (JSON) age: "user_age", // Record key: age (Dart) -> Value: user_age (JSON));
// ErrorKeys - Same structureconst ApiUserErrorKeys = ( name: "name", email: "email_address", // Record key: email (Dart) -> Value: email_address (JSON) age: "user_age", // Record key: age (Dart) -> Value: user_age (JSON));Complete Example
Here’s a comprehensive example showing all generated code features:
import 'package:freezed_annotation/freezed_annotation.dart';import 'package:luthor/luthor.dart';
part 'user_profile.freezed.dart';part 'user_profile.g.dart';
@luthor@freezedabstract class UserProfile with _$UserProfile { const factory UserProfile({ required int id, required String name, @JsonKey(name: 'email_addr') required String email, required UserSettings settings, }) = _UserProfile;
factory UserProfile.fromJson(Map<String, dynamic> json) => _$UserProfileFromJson(json);}
@luthor@freezedabstract class UserSettings with _$UserSettings { const factory UserSettings({ required String theme, required bool notifications, }) = _UserSettings;
factory UserSettings.fromJson(Map<String, dynamic> json) => _$UserSettingsFromJson(json);}
void main() { // Using generated validation final result = $UserProfileValidate({ 'id': 1, 'name': 'John Doe', 'email_addr': 'john@example.com', 'settings': { 'theme': 'dark', 'notifications': true, }, });
switch (result) { case SchemaValidationSuccess(data: final profile): print('✅ Valid profile: ${profile.name}'); // Use the extension method for validation final selfValidation = profile.validateSelf(); print('Self-validation: $selfValidation');
case SchemaValidationError(errors: final errors): // Type-safe error access using ErrorKeys final nameError = result.getError(UserProfileErrorKeys.name); final emailError = result.getError(UserProfileErrorKeys.email); final themeError = result.getError(UserProfileErrorKeys.settings.theme);
print('❌ Validation errors:'); if (nameError != null) print('Name: $nameError'); if (emailError != null) print('Email: $emailError'); if (themeError != null) print('Theme: $themeError'); }}Generated Code
The code generator produces the following:
// Generated SchemaKeysconst UserProfileSchemaKeys = ( id: "id", name: "name", email: "email_addr", // Uses JsonKey name settings: "settings",);
// Generated SchemaValidator $UserProfileSchema = l.withName('UserProfile').schema({ UserProfileSchemaKeys.id: l.int().required(), UserProfileSchemaKeys.name: l.string().required(), UserProfileSchemaKeys.email: l.string().required(), UserProfileSchemaKeys.settings: $UserSettingsSchema.required(),});
// Generated Validation FunctionSchemaValidationResult<UserProfile> $UserProfileValidate( Map<String, dynamic> json,) => $UserProfileSchema.validateSchema(json, fromJson: UserProfile.fromJson);
// Generated Extension for self-validationextension UserProfileValidationExtension on UserProfile { SchemaValidationResult<UserProfile> validateSelf() => $UserProfileValidate(toJson());}
// Generated ErrorKeys with nested structureconst UserProfileErrorKeys = ( id: "id", name: "name", email: "email_addr", settings: ( theme: "settings.theme", notifications: "settings.notifications", ),);Self-Referential and Circular References
The code generator automatically handles self-referential types by detecting when a field references the same class and automatically wrapping it with forwardRef().
Automatic Detection
The generator automatically detects and handles:
- Direct self-references:
Comment? parent - List self-references:
List<Comment>? replies - Map value self-references:
Map<String, Comment>? mentions
@luthor@freezedabstract class Comment with _$Comment { const factory Comment({ required String id, required String text, // Automatic detection - no annotation needed List<Comment>? replies, Comment? parent, Map<String, Comment>? mentions, }) = _Comment;
factory Comment.fromJson(Map<String, dynamic> json) => _$CommentFromJson(json);}Cross-Class Circular References
For cross-class circular references (e.g., User has List<Comment> and Comment has User), use the @luthorForwardRef annotation:
@luthor@freezedabstract class User with _$User { const factory User({ required String id, required String username, List<Comment>? comments, // No annotation needed }) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);}
@luthor@freezedabstract class Comment with _$Comment { const factory Comment({ required String id, required String text, List<Comment>? replies, // Auto-detected - uses forwardRef Comment? parent, // Auto-detected - uses forwardRef Map<String, Comment>? mentions, // Auto-detected - uses forwardRef for values @luthorForwardRef User? user, // Explicit annotation needed for cross-class reference }) = _Comment;
factory Comment.fromJson(Map<String, dynamic> json) => _$CommentFromJson(json);}Generated Code for Forward References
// Generated schema for UserValidator $UserSchema = l.withName('User').schema({ UserSchemaKeys.id: l.string().required(), UserSchemaKeys.username: l.string().required(), UserSchemaKeys.comments: l.list(validators: [$CommentSchema.required()]),});
// Generated schema for CommentValidator $CommentSchema = l.withName('Comment').schema({ CommentSchemaKeys.id: l.string().required(), CommentSchemaKeys.text: l.string().required(), CommentSchemaKeys.replies: l.list( validators: [forwardRef(() => $CommentSchema.required())], // Auto-detected ), CommentSchemaKeys.parent: forwardRef(() => $CommentSchema), // Auto-detected CommentSchemaKeys.mentions: l.map( keyValidator: l.string().required(), valueValidator: forwardRef(() => $CommentSchema.required()), // Auto-detected ), CommentSchemaKeys.user: forwardRef(() => $UserSchema), // Explicit annotation});When to Use @luthorForwardRef
Use @luthorForwardRef when:
- You have cross-class circular references
- Automatic detection doesn’t apply (the generator only detects same-class references)
- You want explicit control over forward reference handling
Note: The annotation must be on the constructor parameter, not the field.
Map Validation with Code Generation
The code generator automatically generates validators for Map<K, V> types, creating both key and value validators:
@luthor@freezedabstract class GameScore with _$GameScore { const factory GameScore({ required Map<String, int> scores, // Generates keyValidator: l.string(), valueValidator: l.int() Map<String, Comment>? mentions, // Generates validators with forwardRef for Comment values }) = _GameScore;
factory GameScore.fromJson(Map<String, dynamic> json) => _$GameScoreFromJson(json);}
@luthor@freezedabstract class Comment with _$Comment { const factory Comment({ required String id, required String text, Map<String, Comment>? mentions, // Self-reference: auto-detected }) = _Comment;
factory Comment.fromJson(Map<String, dynamic> json) => _$CommentFromJson(json);}Generated Map Validation
// Generated schema for GameScoreValidator $GameScoreSchema = l.withName('GameScore').schema({ GameScoreSchemaKeys.scores: l.map( keyValidator: l.string().required(), valueValidator: l.int().required(), ), GameScoreSchemaKeys.mentions: l.map( keyValidator: l.string().required(), valueValidator: $CommentSchema.required(), ),});
// Generated schema for Comment (shows auto-detected forwardRef)Validator $CommentSchema = l.withName('Comment').schema({ CommentSchemaKeys.id: l.string().required(), CommentSchemaKeys.text: l.string().required(), CommentSchemaKeys.mentions: l.map( keyValidator: l.string().required(), valueValidator: forwardRef(() => $CommentSchema.required()), ),});The generator automatically:
- Creates key validators for the map key type
- Creates value validators for the map value type
- Applies
forwardRef()when the value type matches the enclosing class (auto-detected) - Handles nullable types correctly (omits
.required()on the map validator when the field is nullable) - Handles nullable value types (omits
.required()on the value validator whenMap<K, V?>is used)
List Validation with Code Generation
The code generator automatically handles lists with various element types, including nullable elements:
@luthor@freezedabstract class ListExample with _$ListExample { const factory ListExample({ // Non-nullable elements - inner validator gets .required() required List<String> tags, required List<AnotherSample> users,
// Nullable elements - inner validator omits .required() required List<String?> nullableTags, required List<AnotherSample?> nullableUsers,
// Optional list with nullable elements List<String?>? optionalNullableTags, }) = _ListExample;
factory ListExample.fromJson(Map<String, dynamic> json) => _$ListExampleFromJson(json);}Generated List Validation
Validator $ListExampleSchema = l.withName('ListExample').schema({ ListExampleSchemaKeys.tags: l .list(validators: [l.string().required()]) .required(), ListExampleSchemaKeys.users: l .list(validators: [$AnotherSampleSchema.required()]) .required(), ListExampleSchemaKeys.nullableTags: l .list(validators: [l.string()]) // No .required() for nullable elements .required(), ListExampleSchemaKeys.nullableUsers: l .list(validators: [$AnotherSampleSchema]) // No .required() for nullable elements .required(), ListExampleSchemaKeys.optionalNullableTags: l.list( validators: [l.string()], // Optional list, nullable elements ),});The generator automatically:
- Creates list validators with appropriate element validators
- Adds
.required()to element validators when elements are non-nullable - Omits
.required()from element validators when elements are nullable (List<T?>) - Makes the list itself optional when the field is nullable (
List<T>?)
Auto-Generation for Classes Without @luthor
The code generator can automatically generate schemas for classes that don’t have the @luthor annotation, as long as they meet certain compatibility requirements:
// External class without @luthor but compatible for auto-generation@freezedabstract class ExternalUser with _$ExternalUser { const factory ExternalUser({ required String name, required String email, int? age, }) = _ExternalUser;
factory ExternalUser.fromJson(Map<String, dynamic> json) => _$ExternalUserFromJson(json);}
// Class with @luthor that references ExternalUser@luthor@freezedabstract class UserProfile with _$UserProfile { const factory UserProfile({ required int id, required ExternalUser user, // Auto-generated schema ExternalUser? user2, // Auto-generated schema List<ExternalUser>? friends, // Auto-generated schema }) = _UserProfile;
factory UserProfile.fromJson(Map<String, dynamic> json) => _$UserProfileFromJson(json);}Auto-Generated Schema
When a class is referenced but doesn’t have @luthor, the generator automatically creates a schema if the class is compatible:
// Auto-generated schema for ExternalUserconst ExternalUserSchemaKeys = (name: "name", email: "email", age: "age");
Validator $ExternalUserSchema = l.withName('ExternalUser').schema({ ExternalUserSchemaKeys.name: l.string().required(), ExternalUserSchemaKeys.email: l.string().required(), ExternalUserSchemaKeys.age: l.int(),});
SchemaValidationResult<ExternalUser> $ExternalUserValidate( Map<String, dynamic> json,) => $ExternalUserSchema.validateSchema(json, fromJson: ExternalUser.fromJson);
extension ExternalUserValidationExtension on ExternalUser { SchemaValidationResult<ExternalUser> validateSelf() => $ExternalUserValidate(toJson());}Compatibility Requirements:
- Must have a constructor with named parameters
- Must have a
fromJsonfactory constructor OR@MappableClassannotation (for dart_mappable)
Extension Methods
The code generator automatically creates extension methods on your classes for convenient self-validation:
// Generated extensionextension UserValidationExtension on User { SchemaValidationResult<User> validateSelf() => $UserValidate(toJson());}Usage
final user = User(name: 'John', email: 'john@example.com', age: 30);
// Validate the instance against its own schemafinal result = user.validateSelf();
switch (result) { case SchemaValidationSuccess(data: final validatedUser): print('Valid: $validatedUser'); case SchemaValidationError(errors: final errors): print('Errors: $errors');}Cross-Field Validation
Code generation also supports cross-field validation using @WithSchemaCustomValidator. See the Custom Validation documentation for details on implementing validators that can access the entire schema data.
Example
bool passwordsMatch(Object? value, Map<String, Object?> data) { return value == data['password'];}
@luthor@freezedabstract class SignupForm with _$SignupForm { const factory SignupForm({ @IsEmail() required String email, @HasMin(8) required String password, @WithSchemaCustomValidator(passwordsMatch, message: 'Passwords must match') required String confirmPassword, }) = _SignupForm;
factory SignupForm.fromJson(Map<String, dynamic> json) => _$SignupFormFromJson(json);}Generated Schema Custom Validation
Validator $SignupFormSchema = l.withName('SignupForm').schema({ SignupFormSchemaKeys.email: l.string().email().required(), SignupFormSchemaKeys.password: l.string().min(8).required(), SignupFormSchemaKeys.confirmPassword: l .string() .customWithSchema(passwordsMatch, message: 'Passwords must match') .required(),});Best Practices
- Use SchemaKeys - Always use generated SchemaKeys in your schema definitions for type safety
- Use ErrorKeys - Replace string literals with ErrorKeys when accessing validation errors
- Consistent Annotations - Apply
@luthorconsistently across related classes - Automatic Detection - Let the generator handle self-references automatically; only use
@luthorForwardReffor cross-class circular references - Run Generation - Run
dart run build_runner buildafter making changes to annotated classes
The code generation features make Luthor validation both more powerful and safer to use, providing compile-time guarantees and excellent IDE support for your validation logic.