Dart client
A Flutter app is the producer with the least type safety over this API and the most to gain from some. It assembles filters from what a person taps, it pages a list as that person scrolls it, and it does both over a connection that is sometimes a train tunnel.
codegen emits a Dart client for all three, from the same schema declaration the
Go models come from. It is the TypeScript client in a
second language, with the three differences the language forces —
ADR-0031 is where those are argued.
Turning it on
Section titled “Turning it on”Set DartDir on the generator you already have:
codegen.Must(codegen.Generate(codegen.Options{ Registry: schema.DefaultRegistry(), Dir: "blog", Package: "blog",
// Relative to Dir. Two files land here; nothing is emitted without it. DartDir: "mobile/lib/api",}))Two files, and neither imports anything — not dart:io, not a pub package,
not Flutter. There is no framework layer to make optional, because Dart has no
equivalent of TanStack Query to bind to.
runtime.gen.dart |
Page, Collection, Problem, Transport, CursorPager — the vocabulary an application names, and no schema-specific code. |
client.gen.dart |
Row views, request bodies, the typed filter vocabulary, one function per exposed operation. Imports the runtime, and exports it. |
The split is what Dart’s nominal typing forces
(#110). Two clients each
declaring their own Page declare two unrelated classes, so no shared pager
widget could accept both and the application could not give both one
Transport. Two clients exporting one library offer one Page.
Row and the Cond family stay with each client, because both keep a private
contract with the generated code — the _str/_int protocol every row view
inherits, and Cond._encode — and Dart privacy is per library. Duplicating
them costs nothing observable, because nothing can observe them.
Importing the client alone is still enough: client.gen.dart exports the
runtime, so Page, Problem and Transport arrive with it.
The client is emitted into the repository that consumes it, the way
models_gen.go is. There is no pub package to install and therefore no way for
the client to be a version behind the server it talks to. codegen.Check covers
both, so the usual staleness gate catches a schema change that was never
regenerated.
What the types know
Section titled “What the types know”Everything a column declared, and nothing it did not:
final page = await listPosts( transport, params: TaskListParams( where: PostWhere( status: Cond(isIn: [PostStatus.draft, PostStatus.published]), title: TextCond(contains: search), publishedAt: NullableCond(notNull: true), viewCount: Cond(gte: 100), labels: ArrayCond(has: 'urgent'), ), sort: [PostSort.publishedAt.desc, PostSort.title.asc], select: [PostColumn.title, PostColumn.status], expand: [PostExpand.author], perPage: 50, ),);whereadmits filterable columns only, and the condition type is narrowed by the column.TextCondexists only on text, socontainson a number does not compile;NullableCondonly on a nullable column, so neither doesisNullon one that is required; and the value type is the column’s own, so an enum compares against its own members and not against any string.- An array column takes
ArrayCond—hasfor one element,hasAnyandhasAllfor a list,notHas/notHasAny/notHasAllfor their negations, andeqfor the whole array. It carries nocontains, which belongs to text, and none of the ordering operators; the element type is the column’s own, sohason an enum array compares against its members. Reading one back gives aList<String>, and a nullable one distinguishes null from the empty list. sortnames sortable columns, and.asc/.descare the two terms each one offers. An array column is never among them.selectandexpandare closed sets, one enum per resource.- Hidden columns have no spelling anywhere. Not on the row, not in
select, not inwhere.
This is the typed column facade carried across
the wire, and it is why the client is generated from the schema rather than from
the OpenAPI document: the document can only say array<string> about a filter
parameter, with the operators in prose.
A row is a view over the response
Section titled “A row is a view over the response”class Post extends Row { ... }Columns are decoded on access — post.publishedAt parses the timestamp, and a
list of two hundred rows whose screen reads three columns decodes three columns.
Members are lowerCamelCase with the wire spelling beside them, because
snake_case would fail the lowerCamelCase lint in every file that touched it.
That shape is what makes select usable at all. Dart cannot narrow a type by a
runtime projection, so a column the request did not return is reported where it
is read:
final page = await listPosts( transport, params: const PostListParams(select: [PostColumn.title]),);page.items.first.title; // finepage.items.first.status; // throws MissingColumn: Post.status was not in the // response. Add it to select, or drop select to get // every column.row.has(PostColumn.status) is there for code that means to branch on it, and
row.toJson() gives back exactly what arrived, which is what a local cache
wants to store.
An expansion is nullable rather than absent, in both directions:
post.author; // Author? — filled in by expand: [PostExpand.author]author.posts; // Collection<Post>? — {items, hasMore}, cappedThe reverse direction keeps its envelope rather than becoming a bare list, so a screen showing twenty of two hundred can say so.
The transport is yours
Section titled “The transport is yours”The generated functions take a request function rather than constructing one. Base URL, auth header, refresh, retry, offline behaviour and what a 401 does are not derivable from a schema, and are the parts of a real client that matter most. Over Dio:
final Transport transport = (request) async { final response = await dio.request<Object?>( request.query == null || request.query!.isEmpty ? request.path : '${request.path}?${request.query}', options: Options(method: request.method), data: request.body, cancelToken: request.cancel as CancelToken?, ); return response.data;};ApiRequest.cancel is Object? rather than a CancelToken so that the
generated file keeps its promise to import nothing; it is passed through
untouched.
This is the same seam rest takes by mounting onto a huma.API you built. It
also means the generated functions compose with hand-written ones: a login
endpoint is not a table, and no schema generator will produce it.
Paging is a pager
Section titled “Paging is a pager”next_cursor is on every list response with a page after it, which is the walk
an infinite-scrolling list needs:
final feed = postPager( transport, params: PostListParams(sort: [PostSort.publishedAt.desc], perPage: 50),);
await feed.loadMore();feed.items; // what has arrivedfeed.hasMore; // whether to keep goingfeed.isLoading; // for the spinner at the bottomConcurrent loadMore() calls collapse onto the one already running, because a
scroll listener fires on every frame near the end of a list. reset() is
pull-to-refresh. The pager holds rows and a position and nothing about how they
are shown, so it drops into a Riverpod notifier, a BLoC or a StatefulWidget
without preferring any of them:
class PostFeed extends AutoDisposeNotifier<List<Post>> { late final CursorPager<Post> _pager;
@override List<Post> build() { _pager = postPager(ref.read(transportProvider)); return const []; }
Future<void> more() async { await _pager.loadMore(); state = List.of(_pager.items); }}Rejections keep their allow-list
Section titled “Rejections keep their allow-list”A 400 from the filter grammar carries what would have been accepted. The client types that body rather than flattening it to a message, so a filter UI can offer the alternatives instead of a dead end:
final problem = Problem.tryParse(errorBody);final sortable = problem?.allowedFor('query.sort') ?? const [];Writes
Section titled “Writes”A create body carries what a request may write, which is not the row: read-only columns are absent, and a column with a default is optional.
await createPost(transport, const PostCreate(orgId: 'o1', title: 'Hello'));A patch is a builder, one method per writable column, because omitted and explicitly null are different requests and no field can carry that distinction:
final patch = PostPatch() ..title('Hello again') ..publishedAt(null); // writes NULL; a column not named is not written
await updatePost(transport, id, patch);An immutable column has no method here at all — it is settable once, at create.
What is not generated
Section titled “What is not generated”Riverpod providers, BLoCs, widgets, a client object, a pub package. A generated provider is a framework baked in and the thing people copy out and edit, which is the same reason ADR-0028 refuses to generate React hooks.
There is also no cache-key factory, which the TypeScript client does emit. That
one exists because TanStack Query has a keyed cache to invalidate; Dart’s state
managers have no such registry, so keys would be vocabulary with no consumer.
What is emitted instead is TableName, so a change-feed event’s table can be
switched on exhaustively.
example/tasks/mobile is a worked one, including
the refusals — sixteen requests
that must not compile, each suppressed with an // ignore: that the
unnecessary_ignore lint reports if it ever stops being needed. A generator
that widened one of those types fails the build.
- TypeScript client — the same design where the language allows narrowing
- Mounting resources — the server side of the same grammar
- Capabilities — the declarations these types come from