Skip to main content

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> methods
  • Model<User> wrappers
  • response raw and data types

For model services, there are usually two useful layers of typing:

  • the raw server document shape
  • the wrapped client-facing Model<T> & TData shape in data

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 typed FilterQuery<T> surface plus an unrestricted string index signature, so dynamic dotted paths and server-side-cast values still typecheck.
  • ServerSideCast<T> — intent-revealing alias of DottedPathFilter<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 of SuccessResult<TRaw, TData> and FailureResult<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 a Model<T> with reserved wrapper method names omitted
  • DataResponse<T>
  • ArrayDataResponse<T>
  • ListDataResponse<T>
  • SubDocumentResponse<S, TData = S>
  • SubDocumentListResponse<S, TData = S>
  • ModelMutationInput<T>
  • SubDocumentMutationInput<S>
  • Projection
  • FilterQuery<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 typed FilterQuery<T> cannot express the filter.
  • ServerSideCast<T> — intent-revealing alias of DottedPathFilter<T> for explicit server-side casting of field values the client type cannot express.
  • Populate
  • Include
  • WrapOptions

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 for createAdapter(...), including cache policy (cacheTTL, cachePartition, cacheCapacity) and per-adapter onSuccess / onFailure / throwOnError defaults
  • ModelServiceOptions — options for adapter.createModelService<T>(...)
  • DataServiceOptions — options for adapter.createDataService<T>(...)
  • CachePartitioner(config) => string | undefined partition function for credentialed cache safety; returning undefined bypasses the cache for that request so one identity cannot receive a response created under another
  • CacheController — adapter-scoped clear() / dispose() surface used by the returned adapter's clearCache() / disposeCache() methods
  • MissingPersistenceIdentityError — runtime error thrown by Model.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 ModelService reads, raw is usually plain selected document data while data is usually a Model<T> wrapper
  • for DataService reads, raw and data are 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:

  • success
  • raw
  • data
  • status
  • headers

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:

  • detail
  • message
  • title
  • 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.

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-strict
  • pnpm --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.

See also