Code generation
sling_gql_gen is a pure-Dart CLI. Point it at a GraphQL introspection JSON
file and it writes one Dart file: a typed Accessor subclass per object
type, a Dart enum per GraphQL enum, and a class per input object. That file is
the entire “schema binding” — there is no runtime reflection.
Why generation, not a Proxy
Section titled “Why generation, not a Proxy”In JavaScript this model works because Proxy intercepts arbitrary property
access: query.launches.nodes[0].name records each hop as it happens, with
no schema known ahead of time. Dart has no equivalent — there is no way to
intercept .name on an arbitrary object. sling_gql answers this the only way
Dart allows: generate one real getter per field, ahead of time, from the
schema. Every getter is static, typed and tree-shakeable; the interception
happens inside the getter body instead of before it. See
Feasibility notes for the fuller story of
that trade-off.
Running it
Section titled “Running it”# introspect a live endpoint…dart run sling_gql_gen --endpoint https://api.example.com/graphql --out lib/generated/schema.dart
# …or use an introspection result you already havedart run sling_gql_gen --schema graphql/schema.json --out lib/generated/schema.dartFlags:
| Flag | Required | Meaning |
|---|---|---|
--endpoint |
one of | GraphQL URL to introspect (standard introspection query, descriptions included). |
--schema |
one of | Path to an introspection JSON file (the standard {"__schema": {...}} result). |
-H, --header |
no | HTTP header for --endpoint, repeatable: -H "Authorization: Bearer …". |
--out |
yes | Path of the Dart file to write. Parent directories are created as needed. |
--part-of-import |
no | Overrides the sling_gql import. Defaults to package:sling_gql/sling_gql.dart. |
--scalar |
no, repeatable | Custom scalar mapping: Name=DartType[:converterExpr]. See Scalar mapping below. |
The generator writes the file, then shells out to dart format. A
formatting failure is logged to stderr but does not fail the run — you get
unformatted-but-correct code rather than a crash.
What gets emitted
Section titled “What gets emitted”Directorylib/generated/schema.dart
- class Query extends Accessor — the query root, one getter/method per field
- class Launch extends Accessor — one class per other OBJECT type
- class Rocket extends Accessor
- enum LaunchStatus — one Dart enum per ENUM
- class LaunchFilter — one class per INPUT_OBJECT
The query root gets two constructors, the second used once per widget tree:
class Query extends Accessor { Query(super.recorder, super.selection, super.path); Query.root(Recorder r) : super(r, r.root, const []); ...}A field with no arguments becomes a getter that reads straight through the
cache via Accessor’s helpers (scalar, scalarList, object, list —
see accessor.dart). Scalar and enum
fields also get a setter for optimistic writes:
bool? get favorite => scalar<bool>('favorite');set favorite(bool? v) => write('favorite', v);No setter is emitted where a write could never be right: the key field
(--key-field, default id) of a keyed type — writing it would corrupt the
entity key — and connection metadata only the server can know: every field of
PageInfo, and totalCount / pageInfo on connection-shaped types (those
with pageInfo plus nodes or edges).
A field with arguments becomes a method instead of a getter. Required,
non-nullable arguments without a schema default become required Dart
parameters; everything else is optional. The GraphQL type string and the
argument value both flow into an Arg, which is what turns into a GraphQL
variable when the operation is printed:
LaunchConnection? launches({ int? first, String? after, LaunchFilter? filter, LaunchOrder? orderBy,}) => object( 'launches', LaunchConnection.new, args: { 'first': Arg('Int', first), 'after': Arg('String', after), 'filter': Arg('LaunchFilter', filter?.toJson()), 'orderBy': Arg('LaunchOrder', orderBy?.toGraphQL()), },);Each GraphQL enum becomes a real Dart enum, so a switch over it is
exhaustive and a typo is a compile error. Constants are the lowerCamelCase of
the wire name and carry it as graphqlName; a trailing unknown value keeps
old clients working when the server adds a value later:
enum LaunchStatus { scheduled('SCHEDULED'), success('SUCCESS'), failure('FAILURE'), partialFailure('PARTIAL_FAILURE'), scrubbed('SCRUBBED'),
/// A wire value this client does not know (forward compatibility). unknown('');
const LaunchStatus(this.graphqlName);
/// The value as spelled in the GraphQL schema. final String graphqlName;
/// Maps a wire value to its constant, [unknown] when unmatched. static LaunchStatus fromGraphQL(String value) => values.firstWhere((v) => v.graphqlName == value, orElse: () => unknown);
/// The wire value to send as an argument; [unknown] has none. String toGraphQL() { ... }}The cache still holds the wire String; an enum field’s getter maps it on
read through Accessor.enumValue (lists: enumList), which shares the
scalar path so misses, skeletons and dependency tracking behave exactly like
a String field. The setter writes graphqlName back. Enum-typed arguments
and input fields are typed with the enum and serialized with toGraphQL(),
which throws an ArgumentError for unknown — an empty wire value is never
valid:
LaunchStatus? get status => enumValue('status', LaunchStatus.fromGraphQL);set status(LaunchStatus? v) => write('status', v?.graphqlName);A constant that would clash with a Dart keyword or with something the enum
already has (unknown, values, index, name, …) gets the usual
trailing $: UNKNOWN → unknown$, default → default$.
Input objects become plain immutable Dart classes with a toJson() used to
build the Arg value — nothing sling_gql-specific about them:
class LaunchFilter { const LaunchFilter({this.status, this.rocketId, this.year, this.upcoming, this.search});
final LaunchStatus? status; final String? rocketId; final int? year; final bool? upcoming; final String? search;
Map<String, Object?> toJson() => { if (status != null) 'status': status?.toGraphQL(), if (rocketId != null) 'rocketId': rocketId, if (year != null) 'year': year, if (upcoming != null) 'upcoming': upcoming, if (search != null) 'search': search, };}Scalar mapping
Section titled “Scalar mapping”| GraphQL type | Dart type |
|---|---|
String, ID |
String |
Int |
int |
Float |
double |
Boolean |
bool |
Date, DateTime, timestamptz (known custom scalars) |
String |
| any other custom scalar | Object |
The generator has no way to know the JSON shape of an arbitrary custom
scalar, so anything it doesn’t recognize falls back to Object and gets a
doc comment noting it: /// Unknown custom scalar Foo; read as Object?.
The three custom scalars it does know (Date, DateTime, timestamptz)
are all ISO strings on real-world APIs it has been tested against, so they
map to String like the built-in string types by default.
--scalar Name=DartType[:converterExpr] (repeatable) overrides that default
for one scalar name, so the getter returns a richer type instead of the raw
wire string — setters, arguments and input fields all serialize back to the
wire form the same way:
dart run sling_gql_gen --schema graphql/schema.json --out lib/generated/schema.dart \ --scalar DateTime=DateTimeDateTime? get date => scalarAs<DateTime, String>('date', DateTime.parse);set date(DateTime? v) => write('date', v?.toIso8601String());Two forms:
- Built-in:
DartTypeisDateTimeand there is noconverterExpr— reads withDateTime.parse, writes with.toIso8601String(). - Generic: a
converterExprnames a class with staticT parse(String)andString serialize(T)methods, e.g.--scalar Money=Decimal:MoneyConverteremitsscalarAs<Decimal, String>('amount', MoneyConverter.parse)for the getter andMoneyConverter.serialize(v)for the setter/arguments. A converter is required unlessDartTypeisDateTime— the generator has no way to guess how to (de)serialize an arbitrary type otherwise.
Both forms assume the wire value is a JSON string, true of every custom
scalar this generator has been tested against (ISO dates, opaque ids, big
decimals, …). The read path goes through Accessor.scalarAs/scalarListAs
— the same miss/skeleton/dependency-tracking semantics as every other
scalar, just with a conversion step.
Nullability
Section titled “Nullability”Every generated getter and method return type is nullable — T?, R?,
List<R>? — regardless of what the schema says with !. This isn’t a
generator bug: a value can always be absent from cache even when the
schema guarantees the server will never return null for it. Accessor
tells the two cases apart (isSkeleton vs. an explicit server null); see
packages/sling_gql/lib/src/accessor.dart.
Name sanitization
Section titled “Name sanitization”GraphQL names are far more permissive than Dart identifiers. The generator
maps them to safe Dart names while always keeping the original GraphQL name
as the string literal used for cache keys, Arg map keys and toJson keys
— sanitization is purely cosmetic on the Dart side.
- Dart keywords and
Accessormember names get a trailing$:type→type$,class→class$. This also covers accidental collisions withAccessoritself (selection,write,isSkeleton, …). - Enum constants are lowerCamelCased (
PARTIAL_FAILURE→partialFailure,created_at→createdAt) and get the same trailing$on a clash with a keyword or an enum member (unknown,values,index,name, …), or when two wire names camel-case to the same identifier. - A leading underscore (Hasura-style
_eq,_and) is replaced with$so the identifier stays public in Dart:_eq→$eq. - Type names go through the same leading-underscore rule, plus a check
against
dart:core/runtime names (Object,String,List,Map,Cache,Accessor,Selection,Arg,Recorder) —Object→Object$. - A field whose Dart name equals its own return type name (common with
lowercase table types, e.g. a
usersfield returning typeusers) is disambiguated at the member, not the type: it becomesusers$so it doesn’t shadow the class inside its own body.
What’s not supported yet
Section titled “What’s not supported yet”- Mutation and subscription root types are skipped entirely — the generator only emits accessors for the query side; see Roadmap for when that changes.
- Unions and interfaces (
$on) aren’t emitted — the generator only walksOBJECT,ENUMandINPUT_OBJECTkinds. The mock API’s schema doesn’t have any yet either. - Introspection
__*types are filtered out before emission, so they never show up as generated classes.
Keeping generated code and schema in sync
Section titled “Keeping generated code and schema in sync”-
Edit the schema.
mock-api/schema.graphqlis the contract; change it there first. -
Re-run introspection.
Terminal window cd mock-api && npm run introspectThis overwrites
example/graphql/schema.jsonwithout needing the server to be running. -
Regenerate.
Terminal window cd exampledart run ../packages/sling_gql_gen/bin/sling_gql_gen.dart \--schema graphql/schema.json --out lib/generated/schema.dart