skip to content

With graphql_flutter, how do you get typed Dart objects instead of Map<String, dynamic> from query results, and where does code generation fit?

level: middleimportance: should knowfreq 30%

answer

  1. results are maps by default
  2. QueryOptions<TParsed>(parserFn:)
  3. result.parsedData, parsed once
  4. graphql_codegen from .graphql files
  5. parserFn is part of options equality

basics

~10 s

Pass a parserFn to QueryOptions<TParsed> and read result.parsedData, which parses once and caches. graphql_codegen, the generator the package points to, emits typed options, variables and hooks from .graphql files, removing hand-written parsing.

solid answer

~40 s

`QueryResult.data` is a `Map<String, dynamic>` shaped like the selection. Every options class is generic, `QueryOptions<TParsed>`, and takes a `parserFn` that turns that map into your type; `result.parsedData` calls it once and caches the result, so the builder reads `result.parsedData?.book.reviews` with types. Hand-writing parsers for each operation is error-prone, so the package's README points to `graphql_codegen`, which reads `.graphql` operation files and the schema and generates typed result classes, variables classes, options with the parser built in, and hooks. One trap either way: options equality includes `parserFn`, so a new closure created in every `build` makes the `Query` widget treat its options as changed, close the watched query and start it again, which with the default policy means another network request.

code

dart · 30 lines
dart
final bookReviews = gql(r'''
  query BookReviews($bookId: ID!) {
    book(id: $bookId) { id title reviews { id text rating } }
  }
''');

class BookReviewsScreen extends StatelessWidget {
  const BookReviewsScreen({super.key, required this.bookId});
  final String bookId;

  @override
  Widget build(BuildContext context) {
    return Query<BookReviews>(
      options: QueryOptions<BookReviews>(
        document: bookReviews,
        variables: {'bookId': bookId},
        parserFn: BookReviews.fromJson,
      ),
      builder: (result, {refetch, fetchMore}) {
        final page = result.parsedData;
        if (page == null) {
          return result.hasException
              ? Text(result.exception.toString())
              : const CircularProgressIndicator();
        }
        return ReviewList(reviews: page.reviews);
      },
    );
  }
}

go deeper

for a junior

Recall that results arrive as maps and that parserFn with parsedData gives you typed objects.

for a middle

Explain the TParsed generics, how parsedData caches, what graphql_codegen generates, and why options must stay equal across rebuilds.

for a senior

Diagnose repeated network requests from unstable options, and set up codegen so schema changes fail the build rather than the app.

for a principal

Decide how schema, operations and generated code are versioned and shared across teams and apps that consume the same GraphQL API.

## The untyped default Out of the box, every `QueryResult` exposes **`data`** as a `Map<String, dynamic>` whose shape mirrors the operation's selection set. Code like `result.data?['book']?['reviews'] as List?` works, but it carries all the problems of raw maps: typos in keys, casts at every use, and silent breakage when the query changes. `graphql_flutter` offers two ways out. ## parserFn and parsedData All options classes (`QueryOptions`, `MutationOptions`, `SubscriptionOptions`, `WatchQueryOptions`) are generic in **`TParsed`** and accept a **`parserFn`**, a function from the data map to `TParsed`: - `QueryOptions<BookPage>(document: ..., parserFn: BookPage.fromJson)`; - the widgets and hooks are generic too: `Query<BookPage>`, `useQuery<BookPage>`; - `result.parsedData` returns `TParsed?`: `null` when there is no data, otherwise the parser's output, **computed once and cached** on that result object. The parser is typically a `fromJson` factory on a model class, written by hand or generated with the JSON tooling the rest of the app uses. ## Code generation with graphql_codegen The `graphql` and `graphql_flutter` READMEs state that the packages do not generate code themselves and point to **`graphql_codegen`**. Given the schema and `.graphql` files containing your operations, it generates, per operation: 1. **typed result classes** matching exactly the fields the operation selects; 2. **variables classes**, so arguments are checked at compile time; 3. **options classes** with the parser already wired in; 4. **client extensions and hooks**, such as a typed query hook, for use in widgets. The benefits compound: a field removed from the schema becomes a compile error in the generated code, and the packages' README notes it is also faster than parsing documents at runtime. The generator is run by the build tooling like other Dart code generators; how that tooling works is a separate topic. ## The stable-options trap `BaseOptions` implements `==` by deep-comparing its properties: the document, operation name, variables, policies, context, **`parserFn`**, timeout and cancellation token. The `Query` widget's hook compares old and new options on every rebuild, and if they differ it **closes the watched query and starts a new one**. With the default `cacheAndNetwork` policy, that is a network request on every rebuild. What keeps options equal between builds: | Part | Keep stable by | |---|---| | document | parsing once, as a top-level `final` with `gql(...)` | | `parserFn` | a tear-off such as `BookPage.fromJson`, not an inline closure | | variables | building them from values, not from objects recreated each build | | context, timeouts | leaving them unset or creating them once | Generated options avoid most of this, because the document and parser are constants in generated code. ## Typed mutations and variables The same generics apply to writes. `MutationOptions<TParsed>` takes a `parserFn` for the mutation's response, and `Mutation<TParsed>` hands its builder a `QueryResult<TParsed>?`, so the created review can be read as a model. Variables are still a `Map<String, dynamic>` in the hand-written approach, which is where typos in argument names hide; generated variables classes are the main reason teams adopt code generation even when parsing by hand is manageable. ## Choosing an approach - **A few operations**: `parserFn` with hand-written `fromJson` models is enough. - **Many operations or a fast-moving schema**: `graphql_codegen` keeps types and operations in sync. - **Either way**: keep the raw map out of widgets, so a schema change breaks the build, not the running app.

  • Why does an inline parserFn closure cause extra network requests?
    Options equality includes `parserFn`, and a closure literal evaluated in `build` is a new object each time, so the options compare unequal. The `Query` hook then closes and re-creates its watched query, and with the default `cacheAndNetwork` policy each re-creation hits the network. A tear-off such as `BookReviews.fromJson` stays equal.
  • Is parsedData recomputed each time you read it?
    No. The first access runs `parserFn` on `data` and caches the result on that `QueryResult`; later reads return the cached object. A new result, for example after a refetch, parses again.

saying these in an interview costs you the question

  • graphql_flutter generates typed classes from the schema by itself
  • parsedData re-runs parserFn on every access
  • An inline parserFn closure in build() has no side effects
  • Typed results need a separate GraphQL client package
  • QueryResult.data is already a typed object when a schema is available