Skip to content

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.

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.

from your app directory
# 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 have
dart run sling_gql_gen --schema graphql/schema.json --out lib/generated/schema.dart

Flags:

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.

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

example/lib/generated/schema.dart
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:

example/lib/generated/schema.dart
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:

example/lib/generated/schema.dart
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:

example/lib/generated/schema.dart
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:

example/lib/generated/schema.dart
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:

example/lib/generated/schema.dart
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,
};
}
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:

Terminal window
dart run sling_gql_gen --schema graphql/schema.json --out lib/generated/schema.dart \
--scalar DateTime=DateTime
example/lib/generated/schema.dart
DateTime? get date => scalarAs<DateTime, String>('date', DateTime.parse);
set date(DateTime? v) => write('date', v?.toIso8601String());

Two forms:

  • Built-in: DartType is DateTime and there is no converterExpr — reads with DateTime.parse, writes with .toIso8601String().
  • Generic: a converterExpr names a class with static T parse(String) and String serialize(T) methods, e.g. --scalar Money=Decimal:MoneyConverter emits scalarAs<Decimal, String>('amount', MoneyConverter.parse) for the getter and MoneyConverter.serialize(v) for the setter/arguments. A converter is required unless DartType is DateTime — 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.

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.

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 Accessor member names get a trailing $: type → type$, class → class$. This also covers accidental collisions with Accessor itself (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 users field returning type users) is disambiguated at the member, not the type: it becomes users$ so it doesn’t shadow the class inside its own body.
  • 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 walks OBJECT, ENUM and INPUT_OBJECT kinds. 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.
  1. Edit the schema. mock-api/schema.graphql is the contract; change it there first.

  2. Re-run introspection.

    Terminal window
    cd mock-api && npm run introspect

    This overwrites example/graphql/schema.json without needing the server to be running.

  3. Regenerate.

    Terminal window
    cd example
    dart run ../packages/sling_gql_gen/bin/sling_gql_gen.dart \
    --schema graphql/schema.json --out lib/generated/schema.dart