Revali Client generates type-safe client code that mirrors your server's return types. It supports a wide range of types out of the box, including primitives, collections,
Futures, Streams, records, and custom classes.
Overview#
The generated client preserves the exact return types from your server endpoints, ensuring compile-time type safety and automatic serialization/deserialization.
Supported return types:
- Primitive types (String, int, double, bool, etc.)
- Collections (List, Set, Map)
- Async types (Future, Stream)
- Nullable types
- Records (named and positional)
- Enums
- Custom classes with JSON serialization
- Generic types
Built-in Type Support#
Primitive Types#
All Dart primitive types are automatically supported:
// Server
@Controller('data')
class DataController {
@Get('string')
String getString() => 'Hello, World!';
@Get('number')
int getNumber() => 42;
@Get('decimal')
double getDecimal() => 3.14;
@Get('flag')
bool getFlag() => true;
}
Generated client:
abstract class DataDataSource {
Future<String> getString();
Future<int> getNumber();
Future<double> getDecimal();
Future<bool> getFlag();
}
Collections#
Lists, Sets, and Maps are fully supported with type arguments:
// Server
@Controller('collections')
class CollectionsController {
@Get('list')
List<String> getList() => ['a', 'b', 'c'];
@Get('set')
Set<int> getSet() => {1, 2, 3};
@Get('map')
Map<String, dynamic> getMap() => {'key': 'value'};
}
Generated client:
abstract class CollectionsDataSource {
Future<List<String>> getList();
Future<Set<int>> getSet();
Future<Map<String, dynamic>> getMap();
}
Nullable Types#
Nullability is preserved in the generated code:
// Server
@Controller('nullable')
class NullableController {
@Get('optional')
String? getOptional() => null;
@Get('list')
List<String?> getListWithNulls() => ['a', null, 'c'];
}
Generated client:
abstract class NullableDataSource {
Future<String?> getOptional();
Future<List<String?>> getListWithNulls();
}
Enums#
Enums are automatically serialized as strings:
// Shared enum
enum Status { pending, active, completed }
// Server
@Controller('status')
class StatusController {
@Get()
Status getStatus() => Status.active;
}
Generated client:
abstract class StatusDataSource {
Future<Status> getStatus();
}
Async Patterns#
Future#
All synchronous server methods are automatically converted to Future in the client:
// Server - synchronous
@Get('user')
User getUser() => User(name: 'Alice');
// Client - asynchronous
Future<User> getUser();
Server methods that already return Future keep the same signature:
// Server
@Get('user')
Future<User> getUser() async {
return await database.getUser();
}
// Client
Future<User> getUser();
Stream#
Server endpoints that return Stream are preserved in the client:
// Server
@Get('events')
Stream<Event> getEvents() async* {
yield Event('start');
await Future.delayed(Duration(seconds: 1));
yield Event('end');
}
// Client
Stream<Event> getEvents();
Usage:
final events = server.events.getEvents();
await for (final event in events) {
print('Received: ${event.message}');
}
Records#
Dart records are fully supported in both positional and named forms:
Positional Records#
// Server
@Get('coordinates')
(double, double) getCoordinates() {
return (51.5074, -0.1278); // London coordinates
}
// Client
Future<(double, double)> getCoordinates();
Usage:
final (latitude, longitude) = await server.location.getCoordinates();
print('Lat: $latitude, Lon: $longitude');
Named Records#
// Server
@Get('user-info')
({String name, int age}) getUserInfo() {
return (name: 'Alice', age: 30);
}
// Client
Future<({String name, int age})> getUserInfo();
Usage:
final info = await server.user.getUserInfo();
print('${info.name} is ${info.age} years old');
Complex Records#
// Server
@Get('stats')
({int total, List<String> items, bool success}) getStats() {
return (
total: 42,
items: ['a', 'b', 'c'],
success: true,
);
}
// Client
Future<({int total, List<String> items, bool success})> getStats();
Custom Classes#
Custom classes require JSON serialization support to work with Revali Client.
Serialization Requirements#
Every custom class must implement:
fromJsonfactory constructor - For deserializationtoJsonmethod - For serialization
class User {
const User({
required this.id,
required this.name,
required this.email,
});
final String id;
final String name;
final String email;
// Required for deserialization
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
);
}
// Required for serialization
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
};
}
}
Using JSON Serializable#
For complex classes, consider using json_serializable:
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
@JsonSerializable()
class User {
const User({
required this.id,
required this.name,
required this.email,
this.avatar,
});
final String id;
final String name;
final String email;
final String? avatar;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
Then run:
dart run build_runner build
Sharing Types Between Server and Client#
For custom classes to work in both server and client, they must be in a shared package.
The Problem#
If your model is only in the server package:
server_project/
├── lib/
│ └── models/
│ └── user.dart # ❌ Only in server
└── routes/
└── user_controller.dart
The generated client will reference User, but it won't exist in the client project, causing a compile error.
The Solution#
Create a shared package for models:
1. Create the Models Package
dart create models --template package
This creates:
models/
├── lib/
│ └── models.dart
└── pubspec.yaml
2. Add Your Models
Move your model classes to the shared package:
export 'src/user.dart';
export 'src/post.dart';
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
@JsonSerializable()
class User {
const User({
required this.id,
required this.name,
required this.email,
});
final String id;
final String name;
final String email;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
3. Add as Dependency to Server
Update your server's pubspec.yaml:
dependencies:
models:
path: ../models
4. Use in Server
import 'package:models/models.dart';
import 'package:revali_router/revali_router.dart';
@Controller('users')
class UserController {
@Get()
List<User> getAll() {
return [
User(id: '1', name: 'Alice', email: 'alice@example.com'),
User(id: '2', name: 'Bob', email: 'bob@example.com'),
];
}
@Get(':id')
User getById(@Param('id') String id) {
return User(id: id, name: 'Alice', email: 'alice@example.com');
}
@Post()
User create(@Body() User user) {
// Save user
return user;
}
}
5. Client Generation
When you run revali dev or revali build, the generated client will automatically:
- Detect the
modelspackage dependency - Add it to the generated client's
pubspec.yaml - Use the shared types correctly
Generated client:
abstract class UserDataSource {
Future<List<User>> getAll();
Future<User> getById({required String id});
Future<User> create(User user);
}
Project Structure Example#
Recommended project structure for shared types:
my_project/
├── models/ # Shared package
│ ├── lib/
│ │ ├── models.dart
│ │ └── src/
│ │ ├── user.dart
│ │ ├── post.dart
│ │ └── comment.dart
│ └── pubspec.yaml
│
├── server/ # Server package
│ ├── lib/
│ ├── routes/
│ │ ├── user_controller.dart
│ │ ├── post_controller.dart
│ │ └── comment_controller.dart
│ └── pubspec.yaml
│
└── client/ # Your client app
├── lib/
│ └── main.dart
└── pubspec.yaml
Both server/pubspec.yaml and client/pubspec.yaml reference models:
dependencies:
models:
path: ../models
Generic Types#
Generics are preserved in the generated code:
// Shared generic wrapper
class ApiResponse<T> {
const ApiResponse({
required this.data,
required this.success,
this.message,
});
final T data;
final bool success;
final String? message;
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(Object?) fromJsonT,
) {
return ApiResponse(
data: fromJsonT(json['data']),
success: json['success'] as bool,
message: json['message'] as String?,
);
}
Map<String, dynamic> toJson(Object? Function(T) toJsonT) {
return {
'data': toJsonT(data),
'success': success,
'message': message,
};
}
}
// Server
@Get('user/:id')
ApiResponse<User> getUser(@Param('id') String id) {
final user = findUser(id);
return ApiResponse(
data: user,
success: true,
);
}
// Client
Future<ApiResponse<User>> getUser({required String id});
Nested Types#
Complex nested types are fully supported:
// Server
@Get('dashboard')
Map<String, List<User>> getUsersByDepartment() {
return {
'Engineering': [User(...), User(...)],
'Marketing': [User(...), User(...)],
};
}
// Client
Future<Map<String, List<User>>> getUsersByDepartment();
Void Returns#
Methods that don't return data use void:
// Server
@Delete('user/:id')
void deleteUser(@Param('id') String id) {
database.delete(id);
}
// Client
Future<void> deleteUser({required String id});
Usage:
await server.user.deleteUser(id: '123');
print('User deleted');
Best Practices#
1. Use a Shared Models Package#
Always create a separate package for shared types:
// ✅ Good - Shared package
models/
lib/
models.dart
src/
user.dart
// ❌ Bad - Models only in server
server/
lib/
models/
user.dart
2. Implement JSON Serialization#
Always provide fromJson and toJson:
// ✅ Good
class User {
factory User.fromJson(Map<String, dynamic> json) { ... }
Map<String, dynamic> toJson() { ... }
}
// ❌ Bad - No serialization
class User {
final String name;
}
Common Patterns#
Pagination Response#
// Shared model
class PaginatedResponse<T> {
const PaginatedResponse({
required this.items,
required this.total,
required this.page,
required this.pageSize,
});
final List<T> items;
final int total;
final int page;
final int pageSize;
// JSON serialization...
}
// Server
@Get('users')
PaginatedResponse<User> getUsers(
@Query('page') int page,
@Query('size') int size,
) {
// Fetch paginated users
return PaginatedResponse(
items: users,
total: 1000,
page: page,
pageSize: size,
);
}
// Client usage
final response = await server.user.getUsers(page: 1, size: 20);
print('${response.items.length} of ${response.total} users');
Troubleshooting#
Type Not Found Error#
Problem: Generated client references a type that doesn't exist.
Solution: Move the type to a shared package and add it as a dependency to the server.
Serialization Error#
Problem: Runtime error during JSON serialization/deserialization.
Solution: Ensure your class has fromJson and toJson methods.
What's Next?#
Now that you understand return types, explore these related topics:
- Generated Code - Understand the client structure
- HTTP Interceptors - Add request/response handling
- Storage - Manage cookies and sessions
- Configure - Customize client generation