---
title: 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 |
| --- | --- | --- |
| A top-level Ack schema | `@Schemable()` | An immutable Dart model |
| A Dart class | `@Schemable()` | An Ack codec schema and JSON helpers |

`@Schemable()` and the model annotations (`@AckField`, `@Optional`,
`@Required`, `@NotNull`) come from `package:ack/ack.dart`.
Constraint annotations such as `@MinLength` and `@Email` come from
`package:ack/annotations.dart`. Where a constraint name clashes with another
type, such as `package:uuid`'s `Uuid` or your own `Email`, import it with a
prefix and write `@ack.Email()`:

```dart
import 'package:ack/ack.dart';
import 'package:ack/annotations.dart' as ack;
```

`@Uri()` and `@DateTime()` come from `package:ack/format_annotations.dart`,
imported with a prefix because they share names with `dart:core` types.
There is no `@AckSchema()` annotation. `AckSchema<Boundary, Runtime>` is the
runtime type returned by factories such as `Ack.string()` and `Ack.object()`.
`@AckInfer()` and `@AckModel()` are deprecated spellings of `@Schemable()` and
will be removed in 2.0.0.

## Install the generator

```bash
dart pub add ack
dart pub add --dev ack_generator build_runner
```

Every annotated library needs both generated parts:

```dart
part 'models.ack.dart';
part 'models.ack.g.dart';
```

Run the generator after adding or changing a model:

```bash
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.

```dart title="lib/models.dart"
import 'package:ack/ack.dart';
import 'package:ack/annotations.dart';

part 'models.ack.dart';
part 'models.ack.g.dart';

// Schema-first: write the schema; Ack generates Order.
@Schemable()
final orderSchema = Ack.object({
  'id': Ack.string(),
  'total': Ack.double().positive(),
});

// Class-first: write Account; Ack generates AccountSchema.
@Schemable(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:

```dart
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 `@Schemable()` and `@JsonSerializable()` on the same class.

`name` applies only to a top-level schema. `schemaName`, `description`,
`caseStyle`, the discriminator options, `unknownProperties`, and
`captureField` apply only to a class. Generation fails when an option is set
for the other target.

## Schema-first: `@Schemable()` on a top-level schema

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.

```dart
@Schemable()
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 `@Schemable(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`;
- `User.schema`, the model-valued schema for composition such as
  `Ack.list(User.schema)`; it is a shorthand for `User.$ack.modelSchema` and
  is omitted when the model has a field or discriminator named `schema`, the
  annotated declaration is named `schema`, or the library imports a prefix
  named `schema`;
- 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.

Fields may use `Ack.any()` for JSON-safe `Object` values and `Ack.map(...)` for
string-keyed `Map<String, T>` values. `Ack.any().nullable()` infers `Object?`,
and `Ack.map(Ack.any().nullable())` infers `Map<String, Object?>`.

One-way transforms cannot back a generated model because there is no encoder.
Generation also rejects nullable roots, `Ack.any()` and `Ack.map()` roots,
`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 `@Schemable()` object schema in the same library:

```dart
@Schemable()
final catSchema = Ack.object({'lives': Ack.integer()});

@Schemable()
final dogSchema = Ack.object({'breed': Ack.string()});

@Schemable()
final petSchema = Ack.discriminated(
  discriminatorKey: 'type',
  schemas: {'cat': catSchema, 'dog': dogSchema},
);
```

This generates a sealed `Pet` base plus `Cat` and `Dog` branches.

## Class-first: `@Schemable()` on a class

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:

```dart
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 `@Schemable` class and implicit sealed-union branch must
be declared `final class`, initialize its fields through an unnamed generative
constructor, contain only final stored fields, and apply its
generated `_$ClassAck` mixin. The mixin supplies `toJson()`, `safeToJson()`, a
typed `copyWith` where omission keeps the current value, explicit `null`
clears a nullable field, and a wrongly typed argument is a compile-time error,
and deep collection-aware `==`, `hashCode`, and
`toString`. A sealed abstract base receives union serialization only.

Values created by `ProfileSchema.parse` or `Profile.fromJson` receive recursive
unmodifiable copies of list, set, map, and captured-extra values. Ack cannot
rewrite a hand-written constructor, so direct construction and collection
replacements passed to `copyWith` must defensively copy mutable inputs in the
source class when that guarantee is required.

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:

```dart
static final fromJson = ProfileSchema.fromJson;
```

This is a callable static field. If a framework specifically requires a
constructor, use:

```dart
factory Profile.fromJson(Map<String, dynamic> json) =>
    ProfileSchema.fromJson(json);
```

An explicit function-field type is also valid but normally unnecessary:

```dart
static final Profile Function(Map<String, dynamic>) fromJson =
    ProfileSchema.fromJson;
```

The same convention gives the class a `schema` of its own, so other code can
write `Profile.schema`:

```dart
static final schema = ProfileSchema.schema;
```

Generation rejects a `schema` static whose type produces another class.

Use `@Schemable(description: ...)` and `@AckField(description: ...)` to export
schema descriptions. A single-line `@description` tag in a `///` or `/** */`
doc comment is a fallback. Only the text after the tag, on the same line, is exported. Untagged
prose does not become schema data. Explicit annotation text takes precedence.
Blank or duplicate tags fail generation. `@description` is an Ack convention,
not a Dart annotation.

A class description applies to its object schema and model codec. This includes
sealed union bases and branches. A field description applies at its property.
It does not change a nested model's own description.

```dart
/// Tracks one unit of work. This prose is not exported.
@Schemable(description: 'A task the person can complete.')
final class Task with _$TaskAck {
  const Task({required this.id, required this.title});

  final String id;

  /// @description What to do.
  final String title;
}

TaskSchema.toJsonSchema()['description']; // 'A task the person can complete.'
```

Use `@Schemable(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.

Use `@Optional()` or `@Required()` to override inferred key presence. These
control whether the JSON key must exist; they do not change Dart nullability.
Unannotated fields keep constructor inference.

Use `@NotNull()` when the Dart type is nullable (`String?`) but a present JSON
value must not be `null`. Do not add it on non-nullable fields such as
`Map<String, V>` — those already reject JSON `null`. `@Optional()` alone on
`String?` still accepts JSON `null`, matching legacy `presence: optional`
behavior. `@Optional()` is redundant when the constructor already treats the
field as optional; `@NotNull()` is the annotation that changes null acceptance:

```dart
@Schemable()
final class Example with _$ExampleAck {
  const Example({this.label});

  @Optional()
  @NotNull()
  final String? label;
}
```

`ExampleSchema.parse({})` yields `label: null`, `{"label": "hello"}` succeeds,
and `{"label": null}` fails. Encoding omits a null Dart value.

The generator infers `String`, `bool`, numeric types, `DateTime`, `Uri`,
`Duration`, enums, nested lists, and sets. Sets use a list codec. Open JSON
values use the Dart types you already write:

| Dart field | Inferred schema |
| --- | --- |
| `Object` | `Ack.any()` |
| `Object?` | `Ack.any().nullable()`; presence rules match `String?` |
| `List<Object>` | `Ack.list(Ack.any())` |
| `Map<String, T>` | `Ack.map(<schema for T>)` |
| `Map<String, Object?>` | `Ack.map(Ack.any().nullable())` |

`Object` means a JSON-safe value (a finite number, string, boolean, list, or
string-keyed map of those values), not an arbitrary Dart instance, so a
`DateTime` inside an `Object` or `Map<String, Object?>` field fails validation.
Map values may be `null` only when the value type is nullable. `List<Object?>`
stays rejected because `Ack.list` has no nullable-item contract. Parsed
`Object` values are recursively unmodifiable, like other stored collections.

Constraint annotations follow the field type:

- numeric: `@Min`, `@Max`, `@MultipleOf`, `@Positive`, `@Negative`;
- strings: `@MinLength`, `@MaxLength`, `@Matches`, `@Email`, `@NotEmpty`;
- string formats: `@Url`, `@Uuid`, `@Date`; `@Uri()` and `@DateTime()` from
  `package:ack/format_annotations.dart` imported with a prefix
  (`@formats.Uri()`);
- lists and sets: `@MinItems`, `@MaxItems`, `@UniqueItems`.

`@Matches` is the annotation form of `Ack.string().matches(...)`. Numeric
constraint values must be finite. On a `Set<Set<T>>` field the collection
constraints apply to the outer set.

Use `caseStyle` for model-wide JSON names. Supported values are `none`,
`snake`, `kebab`, `pascal`, and `screamingSnake`. Use
`@JsonKey(name: 'wire_name')` on the field for a single override. `JsonKey`
comes from `package:json_annotation/json_annotation.dart`, so a library that
uses it imports that library and lists `json_annotation` in its app's
`dependencies`; `caseStyle` needs neither. Ack resolves
each wire key once and uses it for both validation and JSON mapping. Other
`JsonKey` options, constructor-parameter placement, and `JsonConverter`
annotations are rejected so the Ack schema and generated JSON mapping cannot
diverge.

### Types that own their schema

A type you own can declare its schema once, as a static member named
`schema`. Any field of that type then resolves to it with no annotation on
the field, also as a `List` or `Set` item and as a `Map` value:

```dart
final class Slot {
  const Slot(this.id);
  final String id;

  static final schema = Ack.string().codec<Slot>(
    decode: Slot.new,
    encode: (slot) => slot.id,
  );
}

@Schemable()
final class Section with _$SectionAck {
  const Section({required this.header, this.footer, required this.children});

  final Slot header;          // Slot.schema
  final Slot? footer;         // Slot.schema, optional and nullable
  final List<Slot> children;  // Ack.list(Slot.schema)
}
```

The static can be a field or a getter typed `AckSchema<Boundary, Slot>`. It
works the same on an extension type; declare it `implements Object` so it
satisfies the codec's `Object` bound:

```dart
extension type const WidgetId(String value) implements Object {
  static final schema = Ack.string().codec<WidgetId>(
    decode: WidgetId.new,
    encode: (id) => id.value,
  );
}
```

An enum that declares a static `schema` resolves to it as well, instead of
the default `Ack.enumValues`, so it can map custom wire values.

A static field cannot mention a class's type parameters, so a generic type
declares a static generic method instead. It takes one of two forms. With no
parameters, the generator calls it with the field's type arguments, written as
they are, including `void` and `Object?`:

```dart
final class Command<A> {
  const Command(this.name);
  final String name;

  static AckSchema<String, Command<A>> schema<A>() =>
      Ack.string().codec<Command<A>>(
        decode: Command<A>.new,
        encode: (command) => command.name,
      );
}

// final Command<CompletionAction> open;  ->  Command.schema<CompletionAction>()
// final Command<void> close;             ->  Command.schema<void>()
```

When the schema needs to validate the type argument itself, declare one
positional parameter per type parameter, each typed `AckSchema<Object, A>` for
its type parameter `A`. The generator passes the
schema it infers for each type argument: a facade for a `@Schemable` class, a
built-in schema for a primitive, and the collection schema for a `List`:

```dart
final class Box<A extends Object> {
  const Box(this.value);
  final A value;

  static AckSchema<Object, Box<A>> schema<A extends Object>(
    AckSchema<Object, A> value,
  ) => value.codec<Box<A>>(decode: Box<A>.new, encode: (box) => box.value);
}

// final Box<Row> row;          ->  Box.schema<Row>(RowSchema.schema)
// final Box<String> title;     ->  Box.schema<String>(Ack.string())
// final Box<List<Row>> rows;   ->  Box.schema<List<Row>>(Ack.list(RowSchema.schema))
```

Generation checks the parameter count and that each parameter is a schema of
its type argument. A type argument without an inferable schema fails with the
usual unsupported-type message.

The schema must produce the field's type; generation fails otherwise,
for example when `Slot.schema` produces a `String` or when a generic type
offers a static field instead of a `schema<A>()` method. Schema-first objects
can reference the same members, such as `'header': Slot.schema` or
`'open': Command.schema<CompletionAction>()`; a `void` type argument is
supported only in class-first fields.

A type with neither a static `schema` nor built-in inference fails generation
with a message that names both fixes: declare a static `schema` on the type, or
set `@AckField(schema: ...)` on the field.

### Custom field schemas

Use `@AckField` for a type you cannot edit, or when one field needs a different
schema than its type provides. `schema` is a const tear-off of a non-generic
top-level function returning an `AckSchema` whose runtime type is the field's
type. A mismatched or generic function is rejected. A no-op `@AckField()` is
rejected.

```dart
import 'package:color_library/color.dart';

AckSchema<String, Color> colorSchema() => Ack.string().codec<Color>(
  decode: Color.parse,
  encode: (color) => color.hex,
);

@Schemable()
final class Theme with _$ThemeAck {
  const Theme({required this.primary});

  @AckField(schema: colorSchema)
  final Color primary;
}
```

`@AckField` also replaces an inferred `Object` or `Map<String, V>` schema when
the field needs a tighter contract, such as a function returning
`Ack.map(Ack.integer().min(0))` for a `Map<String, int>` field. Non-String map
keys and `dynamic` are rejected because they do not provide a static schema;
use `Object?` for an open JSON value.
Automatic `List<T>` and `Set<T>` inference also requires non-nullable element
types because `Ack.list` has no nullable-item contract. An explicit
`@AckField(schema: ...)` codec remains the escape hatch when a field deliberately
uses a different collection representation.

`@AckField(presence: ...)` is deprecated. Migrate to field annotations:

```dart
// Optional
@Optional()
final String? summary;

// Required
@Required()
final String? summary;

// Inferred: omit presence annotations

// Schema plus presence
@Optional()
@AckField(schema: parametersSchema)
final Map<String, ParameterEntry> parameters;
```

Matching legacy and new declarations are accepted with a deprecation warning.
Conflicting declarations and combining `@Optional()` with `@Required()` are
errors. `@Optional()` is allowed only when the constructor can accept a missing
value, with a discriminator exception for union branches.

### 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:

```dart
@Schemable(discriminatorKey: 'type')
sealed class Pet with _$PetAck {
  const Pet({required this.id});
  final String id;
}

@Schemable(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 `@Schemable` 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

Raw object validation and class projection are separate decisions.
`Ack.object(..., additionalProperties: true)` accepts unknown keys and preserves
them in the returned `JsonMap`. A later class-first projection then decides
whether the Dart class rejects, discards, or captures those keys.

`@Schemable` uses `AckUnknownPropertyPolicy` through `unknownProperties`:

- `reject` (default) fails validation on unknown properties;
- `discard` accepts unknown properties but does not store them. Use it only for
  tolerant, read-only consumers;
- `capture` stores them in `captureField`, 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. Models that must round-trip
unknown keys must use `capture`, not `discard`. Hand-written constructors can
call `deepUnmodifiableJsonMap` to detach and recursively freeze direct map
inputs without validating them.

### Reusing generated schemas

Nested class-first fields compose through the target facade automatically:

```dart
import 'address.dart' as address;

@Schemable()
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:

```dart
@Schemable()
final envelopeSchema = Ack.object({
  'address': address.AddressSchema.schema,
  'history': Ack.list(address.AddressSchema.schema),
});
```

Class-first fields may also use a model generated from a `@Schemable()`
top-level schema; Ack composes through the generated `Address.$ack.schema`.
Both directions work on the first clean build, including nullable values,
lists, nested lists, and sets. A model generated in the same build cannot yet
be a `Map` value; generation reports that case.

A generated part cannot add imports, so every type a field names must be
visible in the annotated library itself. A type reached only through a
`typedef` from another library, or a codec's runtime type from a library you
did not import, fails generation with the library to import.

The complete import route must expose each authored declaration and generated
companion. For class-first composition that means the hand-written model and
its facade:

```dart
import 'address.dart' show Address, AddressSchema;
```

For schema-first composition it means the annotated schema and generated model
(`addressSchema` plus `Address`). Visibility may be split across multiple
imports using the same prefix, but every barrel export layer must expose the
generated companion. 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?

Put `@Schemable()` on a top-level schema when you want to design the boundary
schema first, generate the whole model, or model a scalar or collection root.
Put it on a class 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:

```yaml title="build.yaml"
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` to dependencies and import `package:ack/ack.dart`.
2. Add `ack_generator` and `build_runner` to dev dependencies.
3. Declare both generated parts in each annotated library.
4. Put `@Schemable()` on a top-level schema or on a class.
5. Run `dart run build_runner build`.

## Migrating from 1.7.0-beta.2

- The `ack_annotations` package is gone. Remove it from `pubspec.yaml`.
  Import `package:ack/ack.dart` for `@Schemable()` and the model annotations,
  and replace `package:ack_annotations/ack_annotations.dart` with
  `package:ack/annotations.dart` in files that use constraint annotations or
  the deprecated `@AckInfer()`, `@AckModel()` and `@AckType()`. Replace
  `package:ack_annotations/format_annotations.dart` with
  `package:ack/format_annotations.dart`.
- Rename `@Pattern(...)` to `@Matches(...)`. The old name remains a deprecated
  alias in `package:ack/annotations.dart` only. Until 2.0.0, an unprefixed
  import of that library hides `dart:core`'s `Pattern` in the file; import it
  `as ack`, or with `hide Pattern`, where the file needs `dart:core`'s
  `Pattern`.
- Replace `@AckInfer()` and `@AckModel()` with `@Schemable()`; the options keep
  their names. The old spellings still generate the same code until 2.0.0,
  as long as `Schemable` is in scope: generated models carry
  `@Schemable.generatedJson`, so an import with `show AckInfer` must also show
  `Schemable`.
- A name that `package:ack/ack.dart` or `package:ack/annotations.dart` exports
  can clash with a same-named class from another library, such as
  `package:uuid`'s `Uuid`, your own `Email` type, or `package:meta`'s
  `Required`. Import `package:ack/annotations.dart` with a prefix
  (`as ack`, then `@ack.Uuid()`), or `hide` the name from one import.
- `ack` no longer depends on `json_annotation` or re-exports `JsonKey`. A
  file that renames a field with `@JsonKey(name: ...)` adds
  `import 'package:json_annotation/json_annotation.dart';` and the app lists
  `json_annotation` in its `dependencies`. `caseStyle` needs neither.
- `Schemable.jsonSerializable`, `AckModel.jsonSerializable`, and
  `AckGeneratedJson.config` are removed; `ack_generator` owns the JSON
  configuration.
- Regenerate: generated schema-first models now carry
  `@Schemable.generatedJson`.
