Skip to content

Type-Safe Query Keys

Type-safe query keys provide compile-time type checking and better IDE support for your queries. This guide shows you how to use TypedQueryKey to create a fully type-safe query key system.

Using type-safe query keys offers several benefits:

  • Compile-time safety: Catch key mismatches at compile time
  • Better IDE support: Autocomplete and refactoring support
  • Type inference: Automatic type inference for query data
  • Centralized management: Single source of truth for all query keys
  • Refactoring safety: Rename keys across your entire codebase safely

For simple cases, you can use the toQueryKey() extension:

QueryBuilder<List<User>>(
queryKey: 'users'.toQueryKey(),
queryFn: () => api.fetchUsers(),
builder: (context, state) => ...,
)

For better type safety, use TypedQueryKey:

// Define query keys in a central location
class QueryKeys {
static TypedQueryKey<List<User>> get users =>
const TypedQueryKey<List<User>>('users', List<User>);
static TypedQueryKey<User> user(String id) =>
TypedQueryKey<User>('user:$id', User);
}
// Use in queries
QueryBuilder<List<User>>(
queryKey: QueryKeys.users,
queryFn: () => api.fetchUsers(),
builder: (context, state) => ...,
)
QueryBuilder<User>(
queryKey: QueryKeys.user('123'),
queryFn: () => api.fetchUser('123'),
builder: (context, state) => ...,
)

Create a centralized QueryKeys class to manage all your query keys:

import 'package:fasq/fasq.dart';
class QueryKeys {
// Simple keys
static TypedQueryKey<List<User>> get users =>
const TypedQueryKey<List<User>>('users', List<User>);
static TypedQueryKey<List<Post>> get posts =>
const TypedQueryKey<List<Post>>('posts', List<Post>);
// Parameterized keys
static TypedQueryKey<User> user(String id) =>
TypedQueryKey<User>('user:$id', User);
static TypedQueryKey<Post> post(String id) =>
TypedQueryKey<Post>('post:$id', Post);
// Complex keys with multiple parameters
static TypedQueryKey<List<Post>> postsByUser(String userId) =>
TypedQueryKey<List<Post>>('posts:user:$userId', List<Post>);
static TypedQueryKey<List<Comment>> commentsByPost(String postId) =>
TypedQueryKey<List<Comment>>('comments:post:$postId', List<Comment>);
}

For queries that depend on parameters, create factory methods:

class QueryKeys {
// Single parameter
static TypedQueryKey<User> user(String id) =>
TypedQueryKey<User>('user:$id', User);
// Multiple parameters
static TypedQueryKey<List<Post>> postsByUserAndPage(
String userId,
int page,
) =>
TypedQueryKey<List<Post>>(
'posts:user:$userId:page:$page',
List<Post>,
);
// Using withParam helper
static TypedQueryKey<List<Post>> postsByUser(String userId) =>
const TypedQueryKey<List<Post>>('posts', List<Post>)
.withParam(userId);
}

All QueryClient methods accept QueryKey:

final client = QueryClient();
// Get query
final query = client.getQuery<List<User>>(
QueryKeys.users,
() => api.fetchUsers(),
);
// Prefetch
await client.prefetchQuery(
QueryKeys.users,
() => api.fetchUsers(),
);
// Invalidate
client.invalidateQuery(QueryKeys.users);
// Set data
client.setQueryData(QueryKeys.users, userList);
// Get data
final users = client.getQueryData<List<User>>(QueryKeys.users);
QueryBuilder<List<User>>(
queryKey: QueryKeys.users,
queryFn: () => api.fetchUsers(),
builder: (context, state) {
if (state.hasData) {
return ListView.builder(
itemCount: state.data!.length,
itemBuilder: (context, index) {
final user = state.data![index]; // Type: User
return UserTile(user);
},
);
}
return CircularProgressIndicator();
},
)
class UserProfile extends StatelessWidget {
final String userId;
const UserProfile({required this.userId});
@override
Widget build(BuildContext context) {
return QueryBuilder<User>(
queryKey: QueryKeys.user(userId),
queryFn: () => api.fetchUser(userId),
builder: (context, state) {
if (state.hasData) {
final user = state.data!; // Type: User
return UserDetails(user);
}
return CircularProgressIndicator();
},
);
}
}
final state = useQuery<List<User>>(
QueryKeys.users,
() => api.fetchUsers(),
);
class UsersCubit extends QueryCubit<List<User>> {
@override
QueryKey get queryKey => QueryKeys.users;
@override
Future<List<User>> Function() get queryFn => () => api.fetchUsers();
}
final usersProvider = queryProvider<List<User>>(
QueryKeys.users,
() => api.fetchUsers(),
);
await client.prefetchQuery(
QueryKeys.users,
() => api.fetchUsers(),
);
await client.prefetchQueries([
PrefetchConfig(
queryKey: QueryKeys.users,
queryFn: () => api.fetchUsers(),
),
PrefetchConfig(
queryKey: QueryKeys.posts,
queryFn: () => api.fetchPosts(),
),
]);

Keep all query keys in a single QueryKeys class:

// Good: Centralized
class QueryKeys {
static TypedQueryKey<List<User>> get users => ...;
}
// Bad: Scattered
final usersKey = 'users'.toQueryKey(); // In file A
final postsKey = 'posts'.toQueryKey(); // In file B

Make query key names descriptive and hierarchical:

// Good: Clear and hierarchical
QueryKeys.user('123')
QueryKeys.postsByUser('123')
QueryKeys.commentsByPost('456')
// Bad: Unclear
QueryKeys.u('123')
QueryKeys.p('123')

Organize related keys together:

class QueryKeys {
// User-related keys
static TypedQueryKey<List<User>> get users => ...;
static TypedQueryKey<User> user(String id) => ...;
static TypedQueryKey<List<Post>> postsByUser(String userId) => ...;
// Post-related keys
static TypedQueryKey<List<Post>> get posts => ...;
static TypedQueryKey<Post> post(String id) => ...;
static TypedQueryKey<List<Comment>> commentsByPost(String postId) => ...;
}

Use const for keys that don’t change:

class QueryKeys {
// Good: const for static keys
static const TypedQueryKey<List<User>> users =
TypedQueryKey<List<User>>('users', List<User>);
// Good: Factory for dynamic keys
static TypedQueryKey<User> user(String id) =>
TypedQueryKey<User>('user:$id', User);
}

Always specify the type parameter:

// Good: Explicit type
static TypedQueryKey<List<User>> get users =>
const TypedQueryKey<List<User>>('users', List<User>);
// Bad: Missing type
static TypedQueryKey get users => ...; // Type is dynamic

For complex data structures, use nested keys:

class QueryKeys {
static TypedQueryKey<List<User>> get users => ...;
static TypedQueryKey<User> user(String id) => ...;
static TypedQueryKey<List<Post>> postsByUser(String userId) =>
TypedQueryKey<List<Post>>('posts:user:$userId', List<Post>);
static TypedQueryKey<Post> postByUser(String userId, String postId) =>
TypedQueryKey<Post>('post:user:$userId:$postId', Post);
}

Use prefixes to group related keys:

class QueryKeys {
static const String _userPrefix = 'user';
static const String _postPrefix = 'post';
static TypedQueryKey<User> user(String id) =>
TypedQueryKey<User>('$_userPrefix:$id', User);
static TypedQueryKey<List<Post>> postsByUser(String userId) =>
TypedQueryKey<List<Post>>('$_postPrefix:$_userPrefix:$userId', List<Post>);
}

Create keys based on conditions:

class QueryKeys {
static TypedQueryKey<List<Post>> posts({
String? userId,
String? category,
}) {
final parts = <String>['posts'];
if (userId != null) parts.add('user:$userId');
if (category != null) parts.add('category:$category');
return TypedQueryKey<List<Post>>(parts.join(':'), List<Post>);
}
}

If you’re migrating from string keys:

  1. Create a QueryKeys class
  2. Replace string keys with TypedQueryKey instances
  3. Update all usages to use the new keys
// Before
QueryBuilder<List<User>>(
queryKey: 'users',
queryFn: () => api.fetchUsers(),
...
)
// After
QueryBuilder<List<User>>(
queryKey: QueryKeys.users,
queryFn: () => api.fetchUsers(),
...
)

You can migrate gradually:

// Temporary: Use extension for backward compatibility
QueryBuilder<List<User>>(
queryKey: 'users'.toQueryKey(),
queryFn: () => api.fetchUsers(),
...
)
// Final: Use type-safe keys
QueryBuilder<List<User>>(
queryKey: QueryKeys.users,
queryFn: () => api.fetchUsers(),
...
)
import 'package:fasq/fasq.dart';
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
}
class Post {
final String id;
final String title;
final String body;
final String userId;
Post({
required this.id,
required this.title,
required this.body,
required this.userId,
});
}
class QueryKeys {
static const TypedQueryKey<List<User>> users =
TypedQueryKey<List<User>>('users', List<User>);
static TypedQueryKey<User> user(String id) =>
TypedQueryKey<User>('user:$id', User);
static const TypedQueryKey<List<Post>> posts =
TypedQueryKey<List<Post>>('posts', List<Post>);
static TypedQueryKey<List<Post>> postsByUser(String userId) =>
TypedQueryKey<List<Post>>('posts:user:$userId', List<Post>);
static TypedQueryKey<Post> post(String id) =>
TypedQueryKey<Post>('post:$id', Post);
}
// Usage
class UsersScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return QueryBuilder<List<User>>(
queryKey: QueryKeys.users,
queryFn: () => api.fetchUsers(),
builder: (context, state) {
if (state.hasData) {
return ListView.builder(
itemCount: state.data!.length,
itemBuilder: (context, index) {
final user = state.data![index];
return ListTile(
title: Text(user.name),
subtitle: Text(user.email),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UserProfile(userId: user.id),
),
);
},
);
},
);
}
return CircularProgressIndicator();
},
);
}
}
class UserProfile extends StatelessWidget {
final String userId;
const UserProfile({required this.userId});
@override
Widget build(BuildContext context) {
return QueryBuilder<User>(
queryKey: QueryKeys.user(userId),
queryFn: () => api.fetchUser(userId),
builder: (context, state) {
if (state.hasData) {
final user = state.data!;
return Scaffold(
appBar: AppBar(title: Text(user.name)),
body: Column(
children: [
Text(user.email),
ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UserPosts(userId: user.id),
),
);
},
child: Text('View Posts'),
),
],
),
);
}
return CircularProgressIndicator();
},
);
}
}

Type-safe query keys provide:

  • Compile-time type checking
  • Better IDE support
  • Centralized key management
  • Refactoring safety
  • Type inference

Start using type-safe query keys today to make your codebase more maintainable and less error-prone!