TypeScript And Errors
This package is strongly typed around your document and data shapes.
Generic Model And Data Types
You usually start by providing a document shape when you create a service.
interface User {
_id?: string;
name: string;
role: string;
public: boolean;
}
const userService = adapter.createModelService<User>({
modelName: 'User',
basePath: 'users',
});
That type then flows through:
ModelService<User>methodsModel<User>wrappers- response
rawanddatatypes
For model services, there are usually two useful layers of typing:
- the raw server document shape
- the wrapped client-facing
Model<T> & TDatashape indata
Selected Field Inference
Advanced methods infer narrower response shapes when select is precise enough.
const user = await userService.readAdvanced('user-1', {
select: ['name', 'role'] as const,
});
user.raw;
// Pick<User, 'name' | 'role'>
Projection styles that participate in inference:
- readonly tuple arrays such as
['name', 'role'] as const - exact string literals such as
'name' - exact projection objects such as
{ name: 1, role: 1 } as const
If the projection is too wide, the client falls back to a looser Partial<T>-style shape.
That fallback is intentional. The client only narrows types when the projection is specific enough to be trustworthy at compile time.
Filter Query Types
FilterQuery<T> is strongly typed around your document shape so invalid known-field values and unsupported operators fail at compile time rather than reaching the server.
interface User {
_id?: string;
name: string;
role: string;
public: boolean;
age: number;
tags: string[];
}
const filter: FilterQuery<User> = {
name: /^Max/, // RegExp on string-typed field
role: { $in: ['admin', 'maintainer'] },
age: { $gte: 18, $lt: 65 }, // comparison operators on number field
public: true, // bare scalar equality
tags: 'vip', // element-typed condition on array field
$or: [{ name: 'Max' }, { age: { $gt: 99 } }],
};
A known field accepts its scalar, an array of those scalars (the sibling server expands it to an $in query), RegExp when the field is string-typed, and the comparison / element / evaluation operators valid for that scalar. Array operands belong in direct conditions or $in / $nin; scalar comparison operators such as $gt and $lte use the scalar or array-element type. Root operators ($and / $nor / $or / $text / $where / $comment) are typed only at the root of a filter. Unknown field keys and unknown operators do not compile:
const bad: FilterQuery<User> = {
nonExistentField: 'x', // error: not a key of User
age: { $regex: '^42' }, // error: $regex is only valid where T extends string
name: { $mod: [10, 0] }, // error: $mod is only valid where T extends number
};
Escape hatches for dynamic dotted paths and server-side casting
The typed FilterQuery<T> deliberately rejects dynamic dotted paths (e.g. 'user.friends.name') because they are not keys of T. When you genuinely need one — or when you want to forward a value explicitly cast on the server side — switch the parameter type to a deliberate, named escape hatch:
DottedPathFilter<T>— the typedFilterQuery<T>surface plus an unrestricted string index signature, so dynamic dotted paths and server-side-cast values still typecheck.ServerSideCast<T>— intent-revealing alias ofDottedPathFilter<T>for explicit server-side casting of field values the client type cannot express.
import { DottedPathFilter, ServerSideCast } from '@web-ts-toolkit/access-router-client';
// Dynamic dotted path the typed surface rejects:
const escaped: DottedPathFilter<User> = {
'user.friends.name': 'Max',
'profile.serverside.cast': 42,
};
// Explicit server-side casting:
const cast: ServerSideCast<User> = {
name: 'Max',
'computed.score': { $gt: 0.5 },
};
The sibling server accepts arbitrary objects/arrays for filters (objectOrArraySchema), so the escape hatch is purely a compile-time opt-out — it never causes a runtime failure. It is also deliberately opt-in: the looseness does not leak back onto the typed FilterQuery<T> surface used everywhere else, so a stray invalid value on a known field in normal call sites still fails to compile.
Overriding The Inferred Shape
You can provide an explicit result type if you want something narrower than the inferred selection.
const user = await userService.readAdvanced<{ name: string }>('user-1', {
select: { name: 1, role: 1 } as const,
});
Mutation Input Types
Model mutation payloads are checked against consumer model fields by default:
userService.create({ name: 'Max' });
userService.update('user-1', { age: 42 });
userService.create({ naem: 'Max' }); // error: misspelled field
userService.update('user-1', { age: 'x' }); // error: wrong scalar type
The default model mutation input is ModelMutationInput<T> (Partial<T>). This catches misspelled fields and wrong scalar values without claiming required create fields the sibling runtime schema cannot infer. If your API accepts request schemas that differ from the response model, pass explicit generics:
type UserCreateInput = { name: string; age: number };
type UserUpdateInput = { name?: string; age?: number };
type UserUpsertInput = { externalId: string; name?: string };
const users = adapter.createModelService<User, UserCreateInput, UserUpdateInput, UserUpsertInput>({
modelName: 'User',
basePath: 'users',
});
Subdocument helpers infer S from array fields and use SubDocumentMutationInput<S> (Partial<S> for object subdocuments) for create/update payloads. Use subs<S, K, TCreateInput, TUpdateInput>(...) when a subdocument request schema differs from the returned subdocument shape.
That is most useful when:
- the server adds derived fields
- you intentionally want to hide part of the selected type at the call site
- you are bridging older code that expects a custom shape
Important Response Types
Common exported types include:
Response<TRaw, TData = TRaw, TError = unknown>— discriminated union ofSuccessResult<TRaw, TData>andFailureResult<TError>SuccessResult<TRaw, TData = TRaw>FailureResult<TError = unknown>ModelResponse<T, TData = T>ArrayModelResponse<T, TData = T>ListModelResponse<T, TData = T>ModelData<T, TData = T>— direct-field data surface of aModel<T>with reserved wrapper method names omittedDataResponse<T>ArrayDataResponse<T>ListDataResponse<T>SubDocumentResponse<S, TData = S>SubDocumentListResponse<S, TData = S>ModelMutationInput<T>SubDocumentMutationInput<S>ProjectionFilterQuery<T>DottedPathFilter<T>— deliberate escape hatch that restores schema-less field matching for dynamic dotted paths such as'user.friends.name'and for explicit server-side casting. Use only at call sites where the typedFilterQuery<T>cannot express the filter.ServerSideCast<T>— intent-revealing alias ofDottedPathFilter<T>for explicit server-side casting of field values the client type cannot express.PopulateIncludeWrapOptions
Public Export Surface
The package is named-export-only (no default export). Configure cache policy and per-factory options through these named types instead of structural inline literals:
AdapterOptions— options forcreateAdapter(...), including cache policy (cacheTTL,cachePartition,cacheCapacity) and per-adapteronSuccess/onFailure/throwOnErrordefaultsModelServiceOptions— options foradapter.createModelService<T>(...)DataServiceOptions— options foradapter.createDataService<T>(...)CachePartitioner—(config) => string | undefinedpartition function for credentialed cache safety; returningundefinedbypasses the cache for that request so one identity cannot receive a response created under anotherCacheController— adapter-scopedclear()/dispose()surface used by the returned adapter'sclearCache()/disposeCache()methodsMissingPersistenceIdentityError— runtime error thrown byModel.save()when a wrapper represents an existing projected document but neither the projection nor the read context provides an identity. Catching this error prevents treating the wrapper as a draft and accidentally creating a copy.
The full root API is locked by the package's runtime/type export contract test
(access-router-client.exports.unit.test.ts); any accidental addition or
removal requires updating that allowlist together with the README and
llms.txt. Implementation internals such as useCacheInterceptors,
cloneConfigWithCacheBypass, finalizeRootEntry, applyGroupCallbacks,
makeRequest, createWrapHelper, ADAPTER_ID_KEY, STARTED_KEY,
CACHE_HEADER, CachePolicy, and RootEntry are intentionally not exported.
WrapOptions is used by wrapped endpoints:
interface WrapOptions {
queryParams?: Record<string, unknown>;
pathParams?: Record<string, string | number>;
}
Two practical distinctions matter a lot:
- for
ModelServicereads,rawis usually plain selected document data whiledatais usually aModel<T>wrapper - for
DataServicereads,rawanddataare usually the same plain value
Error Handling Modes
By default, service methods resolve to normalized failure objects instead of throwing.
const result = await userService.read('missing-id');
if (!result.success) {
console.log(result.status, result.message);
}
If you prefer exceptions, enable throwOnError:
const userService = adapter.createModelService<User>({
modelName: 'User',
basePath: 'users',
throwOnError: true,
});
Or per request:
await userService.read('missing-id', undefined, {
throwOnError: true,
});
In that mode, failed requests reject with ServiceError.
This gives you two consistent styles:
- result-oriented control flow with
if (!result.success) - exception-oriented control flow with
try/catch
ServiceError
ServiceError extends Error and keeps the normalized response fields:
successrawdatastatusheaders
Example:
import { ServiceError } from '@web-ts-toolkit/access-router-client';
try {
await userService.read('missing-id', undefined, { throwOnError: true });
} catch (error) {
if (error instanceof ServiceError) {
console.log(error.status);
console.log(error.message);
console.log(error.raw);
}
}
The message is extracted from structured server payloads in this order when available:
detailmessagetitle- nested entries inside
errors
That makes validation and problem-detail responses much easier to log and display than raw Axios errors.
If the response payload is not structured, the client falls back to stringifying the payload or using the underlying Axios error message.
Lazy Request Type
Service methods return a promise-like LazyRequest<T>.
interface LazyRequest<T> extends Promise<T> {
exec(): Promise<T>;
}
This matters for two reasons:
- you can force execution with
.exec() adapter.group(...)relies on the lazy request metadata attached to these objects
Treat them like promises in normal code, but remember they also carry batching metadata internally.
One Practical Rule
If you plan to batch requests with adapter.group(...), keep them as client-returned lazy requests until the group call.
This works:
const readUser = userService.read('user-1');
const countUsers = userService.count();
const [user, count] = await adapter.group(readUser, countUsers);
This does not:
const user = await userService.read('user-1');
const count = await userService.count();
await adapter.group(user, count);
Once awaited, the lazy request metadata is gone and you no longer have a batchable request object.
Related Pages
Strict consumer compile
The published declarations are checked under strict: true and skipLibCheck: false against fresh NodeNext and Bundler consumers in CI. The package-local commands are:
pnpm --filter @web-ts-toolkit/access-router-client typecheck:nodenext-strictpnpm --filter @web-ts-toolkit/access-router-client typecheck:bundler-strict
Both compile the test-decl-consumer/ directory against dist/index.d.ts with no inference leaks from Axios internals.