Skip to Content
DocsAckAdvancedModel Code Generation

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 pointAnnotationGenerated 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_runner

Every 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 build

A 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 JSON

The 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.parse and User.safeParse for validated input;
  • User.fromJson, toJson, and safeToJson for the JSON boundary;
  • generated copyWith, deep collection-aware ==/hashCode, and toString;
  • a public User.$ack adapter 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.parse and safeParse validate input;
  • ProfileSchema.fromJson is the one-argument map convenience;
  • ProfileSchema.encode and safeEncode validate while encoding;
  • ProfileSchema.toJsonSchema() exports the boundary JSON Schema;
  • ProfileSchema.toSchemaModel() exports Ack’s canonical schema model;
  • ProfileSchema.schema is the typed model codec used for composition;
  • ProfileSchema.wireSchema is the raw structural Map schema.

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

DeclarationInput behaviorEncoding behavior
required TRequired, non-nullAlways present
required T?Required, may be nullPresent even when null
Optional T?May be omittedOmitted when null
Constructor defaultMissing input uses the defaultEncodes 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;
  • discard accepts unknown properties but does not store them;
  • capture stores them in additionalPropertiesField, which defaults to additionalProperties and may be args.

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

Schema-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

  1. Add ack and ack_annotations to dependencies.
  2. Add ack_generator and build_runner to dev dependencies.
  3. Declare both generated parts in each annotated library.
  4. Choose @AckInfer() for schema-first or @AckModel() for class-first.
  5. Run dart run build_runner build.
Last updated on