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

Correlated Include Output Typing​

$include(path) carries an explicit result generic defaulting to unknown, and the outer awaited type gains the output path via WithCorrelatedOutputs. Because TypeScript has no partial type-argument inference, the generic order is path-first: write .$include<'org', Org>('org') so the literal path is preserved in both the untyped ($include('org')) and explicitly typed forms. A lone result generic in first position is rejected by the string constraint instead of silently dropping the merge.

What the types promise — and what they do not:

  • reads contribute Out | null (no guaranteed match is typed), lists contribute Out[], counts contribute number
  • inner values are plain: never Model-wrapped, matching the legacy lean: true payloads that travel over the wire
  • nothing is inferred from partial projections: selecting ['name'] does not narrow Out, and no parent-path validation without a parent type is claimed
  • legacy Include entries and wide (non-literal) paths contribute nothing, so outer types without correlated payloads are exactly their previous shape

Reference positions accept ParentRef markers in bare field values, field operators ($eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $regex, $options, nested $not), $in/$nin elements and whole values, and inside $and/$or/$nor and $elemMatch subtrees — via the CorrelatedFilterQuery / CorrelatedQuerySelector types. Strict FilterQuery shapes reject every reference form at compile time (with a $parent?: never guard), while the correlated overloads admit them; the runtime scan enforces the same boundary for unchecked JavaScript callers. Bare array elements, $exists/$type/$mod, $regex on non-strings, and bare markers as whole $and clauses or whole filters stay type-rejected (the server verdict remains authoritative at runtime). { $escape: ... } literals are data, so escape-only filters stay executable. Existing $$sq subquery values and the ServerSideCast / DottedPathFilter escape hatches keep working alongside markers.

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.
  • UnsupportedServiceDefaultValueError — runtime error thrown for invalid service defaults (functions, symbols, bigints, non-finite numbers, invalid Date values, non-plain instances, cycles) or the separate 64-edge / 10,000-expanded-visit defaults budget. It is defined in src/services/shared.ts and is not re-exported from the package root.
  • CorrelatedIncludeError — package-root export for invalid correlated inputs and cyclic/over-budget query preparation (64 edges / 10,000 expanded visits). Preparation throws synchronously before HTTP; see Client preparation limits.
  • TypeError — direct writes to reserved model members, invalid direct/assign keys, or structural wrapper mutation; see Safe-write restrictions.

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​