Skip to content

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:

Terminal window
dart pub add dev:build_runner dev:luthor_generator

Then add the @luthor annotation to your classes and run code generation:

Terminal window
dart run build_runner build

Generated 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 @JsonKey annotations)

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
@freezed
abstract 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 values
const UserSchemaKeys = (
name: "name",
email: "email",
age: "age",
);

Usage in Schema Definition

The generated schema automatically uses these keys:

// Generated schema using SchemaKeys
Validator $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 SchemaKeys
const UserErrorKeys = (
name: "name",
email: "email",
age: "age",
);
// For nested classes - uses nested records with dot notation
const 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 literals
final emailError = result.getError('user.email');
final themeError = result.getError('settings.theme');

Use type-safe ErrorKeys:

// ✅ Type-safe with autocomplete
final 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
@freezed
abstract 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 values
const 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 structure
const 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
@freezed
abstract 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
@freezed
abstract 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 SchemaKeys
const UserProfileSchemaKeys = (
id: "id",
name: "name",
email: "email_addr", // Uses JsonKey name
settings: "settings",
);
// Generated Schema
Validator $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 Function
SchemaValidationResult<UserProfile> $UserProfileValidate(
Map<String, dynamic> json,
) => $UserProfileSchema.validateSchema(json, fromJson: UserProfile.fromJson);
// Generated Extension for self-validation
extension UserProfileValidationExtension on UserProfile {
SchemaValidationResult<UserProfile> validateSelf() =>
$UserProfileValidate(toJson());
}
// Generated ErrorKeys with nested structure
const 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
@freezed
abstract 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
@freezed
abstract 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
@freezed
abstract 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 User
Validator $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 Comment
Validator $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
@freezed
abstract 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
@freezed
abstract 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 GameScore
Validator $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 when Map<K, V?> is used)

List Validation with Code Generation

The code generator automatically handles lists with various element types, including nullable elements:

@luthor
@freezed
abstract 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
@freezed
abstract 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
@freezed
abstract 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 ExternalUser
const 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 fromJson factory constructor OR @MappableClass annotation (for dart_mappable)

Extension Methods

The code generator automatically creates extension methods on your classes for convenient self-validation:

// Generated extension
extension 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 schema
final 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
@freezed
abstract 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

  1. Use SchemaKeys - Always use generated SchemaKeys in your schema definitions for type safety
  2. Use ErrorKeys - Replace string literals with ErrorKeys when accessing validation errors
  3. Consistent Annotations - Apply @luthor consistently across related classes
  4. Automatic Detection - Let the generator handle self-references automatically; only use @luthorForwardRef for cross-class circular references
  5. Run Generation - Run dart run build_runner build after 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.