Flutter InstantDB
Typed Layer

Typed Relations

Model relations with @InstantLink, typed includes, and recursively-typed fromRow

Relations let a model reference other models. With @InstantLink the generator emits typed relation accessors, a recursively-typed fromRow, and a RelationRef handle for typed link/unlink writes.

Declaring relations

Mark a relation field with @InstantLink. Cardinality is inferred from the field type: List<T> is to-many, a bare T is to-one. The target (T) must itself be an @InstantModel.

@InstantModel('gadgets')
class Gadget {
  final String id;
  final String label;
  const Gadget({required this.id, required this.label});
}

@InstantModel('widgets')
class Widget2 {
  final String id;
  final String name;
  final int weight;

  @InstantLink()
  final List<Gadget> gadgets; // to-many (List<Gadget>)

  const Widget2({
    required this.id,
    required this.name,
    required this.weight,
    required this.gadgets,
  });
}

@InstantLink({attr: '...'}) overrides the stored relation attribute (the include key), which defaults to the field name.

What the generator emits

For each @InstantLink field the generator emits:

  • A typed relation accessor getter returning a TypedQuery of the target table, tagged with the relation attribute:

    TypedQuery<GadgetTable> get gadgets =>
        TypedQuery<GadgetTable>(GadgetTable(), relationAttr: 'gadgets');
  • A RelationRef const for typed link/unlink writes (see Transactions):

    static const gadgetsRel = RelationRef<GadgetTable>('gadgets');
  • A recursively-typed fromRow arm mapping included relation maps to List<T> (to-many) or T? (to-one). Un-included relations safely yield [] / null via a whereType<Map> guard:

    gadgets: (m['gadgets'] as List<dynamic>?)
            ?.whereType<Map<String, dynamic>>()
            .map(GadgetTable().fromRow)
            .toList() ??
        const <Gadget>[],

Without code generation

The generator is a convenience, not a requirement. The whole typed layer — Col, RelationRef, InstantTable, TypedQuery, TypedTx — is plain Dart you can write by hand. A relation is just a RelationRef<TargetTable>('storedAttr'); linking is just an update(...).link({attr: id}) under the hood.

Declare the tables yourself: extend InstantTable<Self>, expose columns as static const Col<T> fields, and expose each relation as a static const RelationRef<TargetTable>:

class UserTable extends InstantTable<UserTable> {
  UserTable() : super('users');
  static const id = Col<String>('id');
  static const name = Col<String>('name');
}

class TodoTable extends InstantTable<TodoTable> {
  TodoTable() : super('todos');
  static const id = Col<String>('id');
  static const text = Col<String>('text');
  static const done = Col<bool>('done');

  // Relation handle — no generator needed. 'author' is the stored attribute.
  static const authorRel = RelationRef<UserTable>('author');
}

Linking and unlinking

TypedTx gives you two link APIs — neither needs generated code:

final todos = db.txFor(TodoTable());

// Typed: pass the RelationRef you declared above. `targetIds` is one id or a List.
await db.transact(todos.linkRel(todoId, TodoTable.authorRel, userId));
await db.transact(todos.unlinkRel(todoId, TodoTable.authorRel, userId));

// Untyped: pass the relation attribute as a plain string.
await db.transact(todos.link(todoId, 'author', userId));
await db.transact(todos.unlink(todoId, 'author', userId));

Both compile to the same update(todoId).link({'author': userId}) op, so the fully untyped form works too if you skip TypedTx entirely:

await db.transact(db.update(todoId).link({'author': userId}));

Including a relation by hand

Without a generated relation accessor, build the included sub-query with the relationAttr: constructor argument — that string becomes the include key:

final q = TodoTable().query().include(
      (t) => TypedQuery<UserTable>(UserTable(), relationAttr: 'author'),
    );
final r = await db.queryOnceTyped(q);
// r.documents[i]['author'] holds the linked user map.

You read results from the raw r.documents maps (the recursively-typed fromRow is the only piece you give up by not generating). Reach for the generator when you want fromRow, typed relation accessor getters, and toMap written for you.

Typed includes

Use TypedQuery.include((t) => t.relation...) to fetch related entities. The relation sub-query supports nested where / order / limit / offset, cursor pagination, and recursive includes. Includes are immutable — the source query is never mutated.

// Fetch goals, each with its linked todos
final q = GoalTable().query().include((g) => g.todos);

// Narrow the included set
final q2 = GoalTable()
    .query()
    .include((g) => g.todos.where((t) => t.n.gte(2)));

// Order + cursor window the relation
final q3 = GoalTable()
    .query()
    .include((g) => g.todos.order((t) => t.n.asc()).first(1));

final r = await db.queryOnceTyped(q3);
final goal = GoalTable().fromRow(r.documents.firstWhere((d) => d['id'] == 'g1'));
final todos = goal.todos; // List<Todo>, in order, windowed

Querying a parent without an include leaves relation fields empty — fromRow yields [] / null rather than crashing.

The .select() restriction

include(...) throws ArgumentError if the relation sub-query carries .select() (a fields projection). The generated fromRow hard-casts every field, so a projected map would cause a TypeError. Use the untyped map API if you need a projected relation.

// Throws ArgumentError:
GoalTable().query().include((g) => g.todos.select((t) => [t.n]));

Per-relation pageInfo

When a nested relation include is cursor-paginated, the engine surfaces its pageInfo under a composite key '<parentType>.<relation>'. Deeper nesting produces dotted keys.

final q = GoalTable()
    .query()
    .include((g) => g.todos.order((t) => t.n.asc()).first(1));

final r = await db.queryOnceTyped(q);
r.pageInfo?['goals.todos']?['hasNextPage']; // true
// Deeper: r.pageInfo?['goals.todos.tags']

Non-paginated includes add no composite pageInfo key. Note that pageInfo is per relation path, not per parent entity: with multiple parents, the key reflects the last parent's window.

On this page