Type safety in Dart
TanStack Query leans on TypeScript: generics that thread a key's shape into
its function, a data type the compiler trusts and nobody checks at runtime.
Dart's type system is sound and has sealed classes, so the port leans on those
instead. The result is fewer type parameters, no !, and one runtime check
that is stricter than you may expect. What you feel at the call site:
The result is sealed
A query result is one of QueryPending, QuerySuccess or QueryError. A
switch over it is exhaustive, so the compiler tells you when a state is not
handled, and the data is a non-nullable field of QuerySuccess — no data!.
QueryError carries staleData, the last good value, for a refetch that
failed with data already on screen. See queries.
Widget deviceTitle(QueryResult<Device> device) => switch (device) {
QueryPending() => const Text('…'),
QuerySuccess(:final data) => Text(data.name),
// `staleData` is a Device? — the last good value, when there was one.
QueryError(:final staleData?) => Text('${staleData.name} (offline)'),
QueryError() => const Text('Unknown device'),
};
Case order matters in the usual Dart way: the QueryError(:final staleData?)
arm matches only when there is stale data, so the plain QueryError() after it
catches the rest.
A mutation result is sealed the same way: MutationIdle, MutationPending,
MutationSuccess, MutationError.
Two type parameters, not five
Query<TQueryData>at the cache layer,QueryObserver<TQueryData, TData>where aselectneeds a second.- There is no
TError: errors are anObjectplus aStackTrace, as everywhere in Dart. - There is no
TQueryKey: aQueryKeyis a value type, deep-frozen and compared by value. See query keys.
Two options shapes
Observer options come in two shapes: QueryObserverOptions<TData>, with no
select and one type argument, and QuerySelectOptions<TQueryData, TData>,
with select required. The required select is what anchors the second type,
so neither shape has a type argument inference cannot fill. See describing a
query once.
The one literal inference cannot type
A key-only options literal — neither a queryFn nor a type argument, relying
on a cached entry or a default query function
— has nothing to infer its data type from, and Dart would silently make it
dynamic. Two things catch it:
- the binding's controllers refuse it in debug builds, with a message naming the cure;
- the analyzer reports it at the literal once
analysis_options.yamlasks for strict inference. Recommended:
analyzer:
language:
strict-inference: true
The cure is an explicit type argument:
// Nothing to infer the type from — no queryFn, no expected type — so name
// it. Without <Device>, this would be a QueryResult<dynamic>.
final device = context.query(
QueryObserverOptions<Device>(queryKey: DeviceKeys.detail(id)),
);
One key, one exact type
A key is bound to the data type it was first used with, and reading it as any
other type throws QueryDataTypeError — related types included. int and
int? are two types. So are List<Device> and List<Object?>.
final key = DeviceKeys.list();
client.setQueryData<List<Device>>(key, const <Device>[]);
client.getQueryData<List<Device>>(key); // the entry's own type: fine
client.getQueryData<List<Object?>>(key); // throws QueryDataTypeError
The error is thrown by the call itself, synchronously, and names the key, the type asked for and the type the entry holds.
TanStack Query casts blindly, and TypeScript cannot tell. Here
getQueryData<T>, getQueriesData<T> and an observer's TQueryData all have
to agree with the key's first use. It is the single most likely thing to catch
you out when porting JavaScript, and it is catching a real bug.
A write is the one place a related type is welcome. setQueryData infers
its type from the value (and so do updateQueryData and updateQueriesData
from the updater), so an entry that already exists takes any value its own
type can hold — a String into a String? query, a sealed type's variant
into a query of the sealed type — and keeps its type:
// The type comes from the value: a Device, into an entry holding a Device.
client.setQueryData(
DeviceKeys.detail(device.id), device.copyWith(isOn: true));
// The type comes from the updater's parameter.
client.updateQueryData(
DeviceKeys.list(),
(List<Device>? devices) => <Device>[
for (final each in devices ?? const <Device>[])
each.id == device.id ? device : each,
],
);
Name the type when the write creates the entry, as when seeding a key before
its query exists: setQueryData<List<Device>>(key, const []). A bare
setQueryData(key, null) writes nothing.
The showcase's diagnostics screen holds an int under one key. Press Read
as String and the facts read read=QueryDataTypeError, expected=String,
actual=int; Read as int succeeds, and Write a String is refused the same
way. The second card shows a mutation with no function failing with
MissingMutationFunctionError until Register a default mutationFn supplies
one:
When one key prefix spans entries of different types, give each type its own key, or store a wrapper type that says what the entry is. See troubleshooting.
Sealed values instead of magic numbers
null means "not configured" on every option field. An option with modes is
a sealed value type — StaleTime, GcTime, Enabled, RetryPolicy,
RetryDelay, RefetchOn, RefetchInterval — never a magic number, string
or boolean. staleTime: 0, staleTime: Infinity and staleTime: 'static'
are three ideas JavaScript squeezes into one field; here they are three
constructors of one sealed class, and a switch over them is exhaustive. See
describing a query once for the
computed forms.
Where the types live in an app
The pattern the guides use: one file per data area under lib/data/, holding
the key factory and one function per query returning fully typed options —
QueryObserverOptions<List<Device>> devicesQuery(...). Widgets call the
function and never write a type argument; the options carry it, the result is
typed from them, and the one place a key is bound to its type is the one
function that builds it. See query keys and
describing a query once.
TanStack's queryOptions() helper and its DataTag-branded keys solve the
same problem — tying a key to its data type — at the type level only. Here the
options value carries the type, and the cache checks it at runtime. See
differences from TanStack Query.
Cancellation and time
- Cancellation is
QueryCancelToken.onCancel, because Dart has no ecosystem-wide cancellation primitive. See query cancellation. - Time goes through
package:clock, so fake time in tests controls staleness and garbage collection completely. See testing.