Model Code Generation
Ack supports code generation in both directions. You can start with a schema and generate a Dart model, or start with a Dart model and generate its schema.
| Starting point | Annotation | Generated result |
|---|---|---|
| An Ack schema | @AckInfer() | An immutable Dart model |
| A Dart class | @AckModel() | An Ack codec schema and JSON helpers |
There is no @AckSchema() annotation. AckSchema<Boundary, Runtime> is the
runtime type returned by factories such as Ack.string() and Ack.object().
The two code-generation annotations are @AckInfer() and @AckModel().
Install the generator
dart pub add ack ack_annotations
dart pub add --dev ack_generator build_runnerEvery annotated library needs both generated parts:
part 'models.ack.dart';
part 'models.ack.g.dart';Run the generator after adding or changing a model:
dart run build_runner buildA working example of both directions
This file contains one schema-first model and one class-first model. Both use the same builders and generated parts.
import 'package:ack/ack.dart';
import 'package:ack_annotations/ack_annotations.dart';
part 'models.ack.dart';
part 'models.ack.g.dart';
// Schema-first: write the schema; Ack generates Order.
@AckInfer()
final orderSchema = Ack.object({
'id': Ack.string(),
'total': Ack.double().positive(),
});
// Class-first: write Account; Ack generates AccountSchema.
@AckModel(caseStyle: AckCaseStyle.snake)
final class Account with _$AccountAck {
const Account({
required this.displayName,
required this.email,
required this.middleName,
this.website,
this.role = 'member',
});
@MinLength(2)
final String displayName;
@Email()
final String email;
final String? middleName;
final Uri? website;
final String role;
static final fromJson = AccountSchema.fromJson;
}After generation, both directions have a typed parsing and JSON boundary:
final order = Order.parse({'id': 'o1', 'total': 12.5});
print(order.total); // double
final account = Account.fromJson({
'display_name': 'Ada',
'email': 'ada@example.com',
'middle_name': null,
});
print(account.role); // member
print(account.toJson()); // validated snake_case JSONThe schema-first and class-first declarations may live in the same library.
Ordinary @JsonSerializable() classes keep their separate .g.dart file,
but do not put @AckModel() and @JsonSerializable() on the same class.
Schema-first with @AckInfer()
Use schema-first generation when the wire contract is the primary artifact. The source schema owns validation, defaults, codecs, and JSON Schema export; Ack generates the stored Dart type around it.
@AckInfer()
final userSchema = Ack.object({
'id': Ack.string().uuid(),
'email': Ack.string().email().nullable(),
'tags': Ack.list(Ack.string()).optional(),
});userSchema generates User: a trailing Schema is removed and no suffix is
added. Use @AckInfer(name: 'AppUser') when you need an exact class name.
Generated models provide:
- an unchecked constructor with stored typed fields;
User.parseandUser.safeParsefor validated input;User.fromJson,toJson, andsafeToJsonfor the JSON boundary;- generated
copyWith, deep collection-aware==/hashCode, andtoString; - a public
User.$ackadapter used by nested generated models.
The generator supports object and value roots, literals, enums, lists, sets, string-keyed maps, built-in and custom bidirectional codecs, named nested models, aliases, defaults, additional properties, named lazy recursion, and same-library discriminated unions. Stored collections are copied recursively into unmodifiable collections.
One-way transforms cannot back a generated model because there is no encoder.
Generation also rejects nullable roots, Ack.any(), Ack.anyOf(), bare
Ack.instance<T>(), anonymous inline object fields, unresolved dynamic schema
factories, and cross-library union branches.
Schema-first unions
Each branch must be a named @AckInfer() object schema in the same library:
@AckInfer()
final catSchema = Ack.object({'lives': Ack.integer()});
@AckInfer()
final dogSchema = Ack.object({'breed': Ack.string()});
@AckInfer()
final petSchema = Ack.discriminated(
discriminatorKey: 'type',
schemas: {'cat': catSchema, 'dog': dogSchema},
);This generates a sealed Pet base plus Cat and Dog branches.
Class-first with @AckModel()
Use class-first generation when the Dart class is the primary artifact. Ack reads constructor-backed fields and generates a codec schema whose runtime value is your class:
final profile = ProfileSchema.parse(payload); // Profile
final result = ProfileSchema.safeParse(payload);
final json = profile.toJson();Ack generates a public ProfileSchema facade and keeps the codec itself in a
library-private _profileSchema variable. The facade is the only public schema
entry point:
ProfileSchema.parseandsafeParsevalidate input;ProfileSchema.fromJsonis the one-argument map convenience;ProfileSchema.encodeandsafeEncodevalidate while encoding;ProfileSchema.toJsonSchema()exports the boundary JSON Schema;ProfileSchema.toSchemaModel()exports Ack’s canonical schema model;ProfileSchema.schemais the typed model codec used for composition;ProfileSchema.wireSchemais the raw structuralMapschema.
Every instantiable @AckModel class and implicit sealed-union branch must
apply its generated _$ClassAck mixin. The mixin supplies toJson(),
safeToJson(), a typed copyWith that treats null as “keep the current
value”, and deep collection-aware ==, hashCode, and toString. A sealed
abstract base receives union serialization only.
Generated toJson() and safeToJson() methods delegate through that same
facade. Ack cannot inject a constructor into a hand-written class, so the
recommended conventional entry point is an inferred static tear-off:
static final fromJson = ProfileSchema.fromJson;This is a callable static field. If a framework specifically requires a constructor, use:
factory Profile.fromJson(Map<String, dynamic> json) =>
ProfileSchema.fromJson(json);An explicit function-field type is also valid but normally unnecessary:
static final Profile Function(Map<String, dynamic>) fromJson =
ProfileSchema.fromJson;Use @AckModel(schemaName: 'WireProfileSchema') to override the exact public
facade name. It must be a public UpperCamel identifier. The private backing
name remains derived from the model class, and no public lower-camel alias is
generated.
Presence comes from the constructor
| Declaration | Input behavior | Encoding behavior |
|---|---|---|
required T | Required, non-null | Always present |
required T? | Required, may be null | Present even when null |
Optional T? | May be omitted | Omitted when null |
| Constructor default | Missing input uses the default | Encodes the stored value, including null |
Nullable defaults keep their source-level behavior. With
this.label = 'fallback', missing input and JSON null both parse as
'fallback', while a directly constructed label: null still encodes as JSON
null. With this.label = null, missing input and JSON null parse as null and
the encoded object keeps the key with a null value.
The generator infers String, bool, numeric types, DateTime, Uri,
Duration, enums, nested lists, and sets. Sets use a list codec.
Constraint annotations follow the field type:
- numeric:
@Min,@Max,@MultipleOf,@Positive,@Negative; - strings:
@MinLength,@MaxLength,@Pattern,@Email,@NotEmpty; - lists and sets:
@MinItems,@MaxItems,@UniqueItems.
Use caseStyle for model-wide JSON names. Supported values are none,
snake, kebab, pascal, and screamingSnake. Use the re-exported
@JsonKey(name: 'wire_name') for a single field override. Ack resolves each
wire key once and uses it for both validation and JSON mapping.
Custom field schemas
Use @AckField when inference is not enough. schema is a const tear-off of a
top-level function returning an AckSchema. presence overrides constructor
inference. At least one of those arguments is required:
final class Color {
const Color(this.hex);
final String hex;
}
AckSchema<String, Color> colorSchema() => Ack.string().codec<Color>(
decode: Color.new,
encode: (color) => color.hex,
);
@AckModel()
final class Theme with _$ThemeAck {
const Theme({required this.primary});
@AckField(schema: colorSchema)
final Color primary;
}Map<String, V> fields also use @AckField; class-first generation does not
invent an Ack.map() runtime API. Non-String map keys, dynamic, and
Object? fields are rejected because they do not provide a static schema.
Class-first unions
Annotate a sealed base with a discriminator key. Concrete branches in the same library are included automatically, and inherited constructor fields may use super parameters:
@AckModel(discriminatorKey: 'type')
sealed class Pet with _$PetAck {
const Pet({required this.id});
final String id;
}
@AckModel(discriminatorValue: 'cat')
final class Cat extends Pet with _$CatAck {
const Cat({required super.id, required this.lives});
final int lives;
}
final class Dog extends Pet with _$DogAck {
const Dog({required super.id, required this.breed});
final String breed;
}The base and every concrete branch receive facades (PetSchema, CatSchema,
and DogSchema), including branches without their own @AckModel annotation.
Without an explicit discriminatorValue, the wire value is the verbatim class
name (Dog above). Set explicit values when the wire format must remain stable
through class renames. Class-first anyOf and value roots are not supported;
use schema-first generation for those shapes.
Additional properties
@AckModel uses AckAdditionalPropertiesMode:
reject(default) fails validation on unknown properties;discardaccepts unknown properties but does not store them;capturestores them inadditionalPropertiesField, which defaults toadditionalPropertiesand may beargs.
Capture requires a declared Map<String, Object?> field initialized by the
constructor. Encoding writes extras first, so a declared field or
discriminator cannot be replaced by an extra value.
@AckField can override inferred presence with AckFieldPresence.required or
optional. A no-op @AckField() is rejected. optional is allowed only when
the constructor can accept a missing value, with a discriminator exception for
union branches.
Reusing generated schemas
Nested class-first fields compose through the target facade automatically:
import 'address.dart' as address;
@AckModel()
final class Order with _$OrderAck {
const Order({required this.shipping});
final address.Address shipping;
}
// Generated field schema: address.AddressSchema.schemaSchema-first declarations can use the same facade explicitly:
@AckInfer()
final envelopeSchema = Ack.object({
'address': address.AddressSchema.schema,
'history': Ack.list(address.AddressSchema.schema),
});Class-first fields may also use a model generated by @AckInfer(); Ack composes
through the generated Address.$ack.schema. Both directions work on the first
clean build, including nullable values, lists, nested lists, and sets.
An import combinator must expose both the hand-written model and its facade:
import 'address.dart' show Address, AddressSchema;The same rule applies to barrel exports. Prefixed imports avoid ambiguity when two libraries declare the same model name. Deferred imports are unsupported.
Automatic recursive class-first schemas are not yet defined. For self or
mutually recursive contracts, use schema-first named Ack.lazy schemas.
Which direction should you choose?
Choose @AckInfer() when you want to design the boundary schema first, generate
the whole model, or model a scalar or collection root. Choose @AckModel()
when you already own the class, want to keep methods and constructors in source,
or prefer field annotations over a separate object schema.
This choice is per model, not per project. A migration can keep existing schema-first types while new domain-owned types use class-first generation.
Limit generation in larger projects
Use matching generate_for entries for both Ack phases. This reduces analyzer
work and keeps the JSON phase scoped to libraries whose .ack.dart input is
generated:
targets:
$default:
builders:
ack_generator|ack_models:
generate_for: [lib/models/**.dart]
ack_generator|ack_model_json:
generate_for: [lib/models/**.dart]Both entries must cover the same annotated libraries.
Build checklist
- Add
ackandack_annotationsto dependencies. - Add
ack_generatorandbuild_runnerto dev dependencies. - Declare both generated parts in each annotated library.
- Choose
@AckInfer()for schema-first or@AckModel()for class-first. - Run
dart run build_runner build.