result_kit 0.2.0
result_kit: ^0.2.0 copied to clipboard
Typed results and async state models for clean Dart and Flutter applications.
result_kit #
Typed results, async state machines, and a network guard layer for clean-architecture Dart & Flutter apps.
Features #
Result<T>— typed success/failure without exceptionsFailure— five semantic variants:network,auth,server,unknown,validationDataState<T>— five-state machine for async data-fetching (initial → loading → success / empty / failure)ActionState<T>— four-state machine for async mutations (initial → loading → success / failure)PaginatedDataState<T>— paginated list state with load-more supportApiGuard— network-agnostic error guard with anApiAdapterinterfaceDioAdapter— bundled Dio implementation ofApiAdapter- Use-case interfaces —
AsyncUseCase,AsyncUseCaseNoParams,SyncUseCase,SyncUseCaseNoParams
Installation #
dependencies:
result_kit: ^0.1.0
Usage #
Result<T> #
Use Result<T> as a return type instead of throwing exceptions. Pattern-match with when, or use the
extension helpers.
Result<User> fetchUser(int id) {
if (id <= 0) return const Result.err(Failure.unknown('Invalid id'));
return Result.ok(User(id: id, name: 'Alice'));
}
final result = fetchUser(1);
// Pattern match
result.when(
ok: (user) => print('Got ${user.name}'),
err: (failure) => print('Error: ${failure.message}'),
);
// Extension helpers
if (result.isOk) print(result.valueOrNull);
if (result.isErr) print(result.failureOrNull?.message);
// Fold to a value
final label = result.fold(
ok: (user) => user.name,
err: (f) => 'Unknown',
);
Failure #
Failure is a sealed type with five variants. Use the .message getter on any variant without
pattern-matching.
| Variant | When to use |
|---|---|
Failure.network |
No connectivity or request timed out |
Failure.auth |
Missing or invalid credentials (HTTP 401) |
Failure.server |
Non-2xx, non-401 server response |
Failure.validation |
Client-side input invalid before the request is made |
Failure.unknown |
Unexpected exception that fits no other category |
void handle(Failure failure) {
switch (failure) {
case NetworkFailure():
showOfflineBanner();
case AuthFailure():
navigateToLogin();
case ValidationFailure():
showFieldError(failure.message);
case ServerFailure():
case UnknownFailure():
showErrorSnackbar(failure.message);
}
}
DataState<T> #
A five-state machine for async data-fetching in a repository or BLoC/Cubit.
// In your cubit / state notifier
Future<void> loadUsers() async {
emit(const DataState.loading());
final result = await _repo.getUsers();
result.when(
ok: (users) => emit(
users.isEmpty ? const DataState.empty() : DataState.success(users),
),
err: (failure) => emit(DataState.failure(failure)),
);
}
// In your widget
switch (state) {
case DataInitial() || DataLoading():
return const CircularProgressIndicator();
case DataSuccess(:final item):
return UserList(users: item);
case DataEmpty():
return const Text('No users found.');
case DataFailure(:final failure):
return Text(failure.message);
}
Extension helpers let you skip pattern-matching for simple checks:
if (state.isLoading) showSpinner();
if (state.isFailure) showError(state.errorMessage);
final users = state.item; // T? — null when not success
ActionState<T> #
A four-state machine for mutations (create, update, delete). The success state optionally carries a
return value — pass T = void for fire-and-forget actions.
// Fire-and-forget delete
Future<void> deleteUser(int id) async {
emit(const ActionState<void>.loading());
final result = await _repo.deleteUser(id);
result.when(
ok: (_) => emit(const ActionState<void>.success()),
err: (failure) => emit(ActionState.failure(failure)),
);
}
// Action that returns a value
Future<void> createUser(String name) async {
emit(const ActionState<User>.loading());
final result = await _repo.createUser(name);
result.when(
ok: (user) => emit(ActionState.success(user)),
err: (failure) => emit(ActionState.failure(failure)),
);
}
Extension helpers:
if (state.isLoading) showSpinner();
if (state.isFailure) showError(state.errorMessage);
final createdUser = state.data; // T? — null when not success
PaginatedDataState<T> #
A five-state machine for paginated lists. The success state carries items, pagination metadata, and
an isLoadingMore flag for "load next page" UX.
Future<void> loadFirstPage() async {
emit(const PaginatedDataState.loading());
final result = await _repo.getUsers(page: 1);
result.when(
ok: (page) => emit(
page.items.isEmpty
? const PaginatedDataState.empty()
: PaginatedDataState.success(
items: page.items,
metadata: page.metadata,
),
),
err: (failure) => emit(PaginatedDataState.failure(failure)),
);
}
Future<void> loadNextPage() async {
final current = state;
if (!current.hasMore || current.isLoadingMore) return;
// Flag "loading more" while keeping existing items visible
emit(
PaginatedDataState.success(
items: current.items!,
metadata: current.metadata!,
isLoadingMore: true,
),
);
final result = await _repo.getUsers(page: current.metadata!.currentPage + 1);
result.when(
ok: (page) => emit(
PaginatedDataState.success(
items: [...current.items!, ...page.items],
metadata: page.metadata,
),
),
err: (failure) => emit(PaginatedDataState.failure(failure)),
);
}
Extension helpers:
state.items // List<T>? — null when not success
state.metadata // PaginationMetadata? — null when not success
state.hasMore // bool — true when metadata.hasNextPage is true
state.isLoadingMore // bool — true while fetching the next page
state.errorMessage // String? — message when failure
PaginationMetadata #
Parse pagination metadata from a JSON response. Field names are configurable to match any backend.
// Default field names: page, limit, totalItems, totalPages
final meta = PaginationMetadata.fromJson(response.data as Map<String, dynamic>);
// Custom field names
final meta = PaginationMetadata.fromJson(
response.data as Map<String, dynamic>,
pageKey: 'pageNumber',
limitKey: 'pageSize',
totalItemsKey: 'total',
totalPagesKey: 'pages',
);
meta.currentPage // int
meta.itemsPerPage // int
meta.totalItems // int
meta.totalPages // int
meta.hasNextPage // bool
meta.hasPrevPage // bool
meta.skip // int
meta.limit // int
meta.nextPage // int
Network layer — ApiGuard & ApiAdapter #
ApiGuard.run wraps any HTTP call, parses the response, and returns a Result<T>. It is
network-library agnostic — plug in any client by implementing ApiAdapter.
With DioAdapter (built-in)
final result = await ApiGuard.run(
adapter: DioAdapter(() => dio.get('/users')),
parser: (res) => (res.data as List)
.map((e) => User.fromJson(e as Map<String, dynamic>))
.toList(),
);
Custom ApiAdapter
Implement ApiAdapter to connect any HTTP client:
class HttpAdapter implements ApiAdapter {
const HttpAdapter(this._request);
final Future<http.Response> Function() _request;
@override
Future<ApiResponse> execute() async {
try {
final res = await _request();
return ApiResponse(statusCode: res.statusCode, data: jsonDecode(res.body));
} on SocketException {
throw ApiAdapterException(const Failure.network('No internet connection'));
}
}
}
// Usage
final result = await ApiGuard.run(
adapter: HttpAdapter(() => http.get(Uri.parse('/users'))),
parser: (res) => User.fromJson(res.data as Map<String, dynamic>),
);
Use-case interfaces #
Enforce a consistent call-site contract across your feature layer:
class GetUserUseCase implements AsyncUseCase<User, int> {
const GetUserUseCase(this._repo);
final UserRepository _repo;
@override
Future<Result<User>> call(int id) => _repo.getUser(id);
}
// Fire without params
class RefreshSessionUseCase implements AsyncUseCaseNoParams<void> {
@override
Future<Result<void>> call() => _authService.refresh();
}
Synchronous variants follow the same pattern but return T directly instead of Future<Result<T>>:
class ValidateEmailUseCase implements SyncUseCase<String?, String> {
@override
String? call(String email) {
if (email.isEmpty) return 'Email is required';
if (!email.contains('@')) return 'Invalid email';
return null; // null = valid
}
}
// No-params sync variant
class GetCachedTokenUseCase implements SyncUseCaseNoParams<String?> {
@override
String? call() => TokenStore.token;
}
Customisation #
Custom error message extraction (DioAdapter) #
Override how the error message is extracted from a non-2xx response body:
DioAdapter(
() => dio.post('/login', data: body),
messageExtractor: (data) {
if (data is Map) return data['errorMessage'] as String?;
return null;
},
)
Custom DioException mapping #
Override the entire DioException → Failure mapping:
DioAdapter(
() => dio.get('/profile'),
failureMapper: (e) => switch (e.response?.statusCode) {
401 || 403 => const Failure.auth('Access denied'),
503 => const Failure.network('Service unavailable'),
_ => Failure.server(e.message ?? 'Server error'),
},
)
Custom non-2xx handling (ApiGuard) #
Override how non-2xx responses from any adapter are mapped to a Failure:
ApiGuard.run(
adapter: adapter,
parser: (res) => ...,
onNonSuccess: (res) => switch (res.statusCode) {
403 => const Failure.auth('Forbidden'),
422 => Failure.server(_parseValidationErrors(res.data)),
_ => Failure.server('HTTP ${res.statusCode}'),
},
)
Issues & contributions #
File bugs and feature requests at the GitHub issue tracker.