Minimal TypeScript toolkit for async data fetching β typed errors, chainable transforms, zero-config mocking.
Most async data layers grow into a tangle of try/catch blocks, ad-hoc mocks, and one-off error handling scattered across components. Services is built to fix that at the source β the main goal is to make async data work feel smooth and predictable, so developers spend less time on plumbing and more time building features.
A few guiding principles:
- Errors are values, not exceptions.
[error, data]tuples make error handling explicit and type-safe β no surprises, no forgotten catch blocks. - Define once, run everywhere. Transforms, error handlers, and mocks are declared on the provider, not at every call site. Change behavior in one place.
- Mocking should cost nothing. Tests and dev mode use real mock files β no framework magic, no
vi.mockgymnastics. Toggle from the console, swap per test with.andMock(). - No hidden abstractions. The provider is just a function.
await getUser(id)β it does exactly what you'd expect. - Colocation over convention. Each feature owns its
api,mock,normalize, andproviderfiles. The data layer lives next to the feature that uses it.
- π‘οΈ
[error, data]tuples βcreateSafeProvidernever throws; errors are typed values - π Chainable API β
.andMock().andThen().andCatch().andFinally()compose once, apply on every call - π§ͺ Zero-config testing β mocks activate automatically in
NODE_ENV=test; swap them per test with.andMock() - π Dev mocking β toggle mocks from the browser console without touching source code
- β‘ Abort support β
AbortSignalflows through providers anddelay()helpers
| Module | Purpose | Docs |
|---|---|---|
data-provider |
Wrap async functions in a chainable, mockable, type-safe API | API Β· Usage |
mock |
Toggle mock mode, simulate latency, SSR-compatible state | API Β· Usage |
safe |
Wrap any function to return [error, data] instead of throwing |
API |
logger |
Structured console logger, silent in production | API Β· Usage |
This is a suggested folder structure β not enforced by the library. Use whatever layout fits your project. The pattern that works well in practice:
src/
βββ modules/
βββ user/
βββ models/
β βββ user.model.ts β types and validation schema, API and UI models
βββ data-provider/
βββ api.ts β real async function
βββ mock.ts β mock using delay()
βββ normalize.ts β pure function to transform API response to UI model
βββ provider.ts β wires api, mock and transforms together; also a natural place for React Query options
βββ index.ts β export * from './provider'
api.ts β fetch from real endpoint
import ky from 'ky';
import { userApiSchema } from '../models/user.model';
export const getUser = (id: string, signal?: AbortSignal) =>
ky.get(`/api/users/${id}`, { signal }).json(userApiSchema);
kycan be extended to add runtime schema validation β keeping individualapi.tsfiles clean while ensuring runtime types always match build-time types.
mock.ts β simulated response with realistic delay
import { delay } from '@rstackio/services/mock';
export const getUser = (id: string, signal?: AbortSignal) =>
delay(() => ({ id, firstName: 'Jane', lastName: 'Doe', role: 'admin' as const }), { signal, delayMs: 300 });normalize.ts β pure transform, easy to test in isolation
import type { UserApi, User } from '../models/user.model';
export const normalize = (user: UserApi): User => ({
...user,
fullName: `${user.firstName} ${user.lastName}`,
});provider.ts β wires everything together once
import { createSafeProvider } from '@rstackio/services/data-provider';
import * as api from './api';
import * as mock from './mock';
import { normalize } from './normalize';
export const getUser = createSafeProvider(api.getUser)
.andMock(mock.getUser) // β
mock must match the exact signature of api.getUser
.andThen(normalize); // β
final type is inferred from normalize's return type β callers get User, not UserApiType safety β
.andMock()enforces that the mock matches the original signature..andThen()transforms the result type β the caller always sees the output of the last transform, fully inferred with no manual annotations needed.
index.ts β single public entry point
export * from './provider';Consume anywhere in the module:
import * as Dp from './data-provider';
const [error, user] = await Dp.getUser(id);npm install @rstackio/services
# or
pnpm add @rstackio/services
# or
yarn add @rstackio/servicesRequires TypeScript 5.0+ with strict: true.
pnpm install
pnpm test # run tests
pnpm test:watch # watch mode
pnpm build # build distSee CONTRIBUTING.md for the full guide, including how to add a changeset to your PR.
Amazing people who made their contributions. Feel free to contribute!