[Feature Request] Naming consistency, request interceptors, and per-collection type files
Overview
Three related improvements to the generated client and generator output:
- Naming consistency —
upload.destroy vs delete, authentication vs auth
- Request/response interceptors — hook into the request lifecycle for token
management, logging, and global error handling
- Per-collection type files — split the single
types.ts into one file per
collection for better IDE performance, faster incremental builds, and readable diffs
1. Naming Consistency
1.1 upload.destroy vs upload.delete
UploadAPI is the only API across the entire generated client that uses destroy
instead of delete. Every CollectionAPI uses delete:
// Every collection — consistent
await client.collection.delete(documentId);
// UploadAPI — breaks the pattern
await client.upload.destroy(id); // ← only method across the whole client that uses this name
destroy is the internal Strapi controller action name
(plugin::upload.controllers.content-api.destroy) and arguably should not surface in
the public client API, which otherwise has no awareness of internal controller naming.
Open for discussion: whether delete is the right name, or whether there is a
reason to keep destroy (e.g. to signal that the operation is permanent and removes
the file from storage, not just a soft delete).
1.2 authentication vs auth
The auth namespace is the only property on StrapiClient that uses a long-form name.
Most SDKs and clients in the ecosystem use the shorter auth:
// Current
await client.authentication.login({ identifier, password });
await client.authentication.me();
// Shorter alternative
await client.auth.login({ identifier, password });
await client.auth.me();
Open for discussion: whether authentication should be replaced by auth,
kept alongside it, or left as-is. If renamed, keeping authentication as a
non-breaking alias is straightforward:
/** @deprecated Use `auth` instead. */
get authentication() {
return this.auth;
}
This way existing consumers continue to work without changes while new code uses
the shorter form.
2. Request/Response Interceptors
Interceptors allow consumers to hook into the request lifecycle without subclassing
or patching the client. This is the most commonly requested pattern across HTTP
client libraries and covers use cases that cannot be solved at the call site.
Proposed StrapiClientConfig additions
export interface RequestConfig {
url: string;
method: string;
headers: Record<string, string>;
body?: string | FormData;
}
export interface StrapiClientConfig {
baseURL: string;
token?: string;
fetch?: typeof fetch;
debug?: boolean;
credentials?: RequestCredentials;
timeout?: number;
validateSchema?: boolean;
// --- new ---
/**
* Called before every request. Return a modified config to change headers,
* URL, or body. Async — safe to call token refresh or storage reads here.
*/
onRequest?: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>;
/**
* Called after every successful (2xx) response before the body is parsed.
* Use for logging, metrics, or response transformation.
*/
onResponse?: (response: Response, config: RequestConfig) => Response | Promise<Response>;
/**
* Called when a request throws a StrapiError or StrapiConnectionError.
* Must always re-throw or redirect — returning without throwing is a runtime error.
*/
onError?: (
error: StrapiError | StrapiConnectionError,
config: RequestConfig
) => never | Promise<never>;
}
Use case 1 — Dynamic token injection (most common)
The primary motivation: reading the token from any async source at request time
rather than managing it imperatively via setToken().
const client = new StrapiClient({
baseURL: 'https://api.example.com',
onRequest: async (config) => {
const token = await getTokenFromStorage(); // SecureStore, AsyncStorage, keychain, etc.
if (!token) return config;
return {
...config,
headers: { ...config.headers, Authorization: `Bearer ${token}` },
};
},
});
This eliminates the need to call setToken() on login/logout/restore and removes
any possibility of the client holding a stale token. The token is read from the
authoritative source on every request.
Note: Libraries that manage their own query/request lifecycle (such as query
caching libraries) often have their own retry and error handling mechanisms. In
those setups, onError on the client level can still be useful for global
side-effects (clearing storage, redirecting), while per-request error handling
is left to the calling layer.
Use case 2 — Logging and metrics
const client = new StrapiClient({
baseURL: 'https://api.example.com',
onRequest: async (config) => {
console.log(`→ ${config.method} ${config.url}`);
return config;
},
onResponse: async (response, config) => {
console.log(`← ${response.status} ${config.url}`);
return response;
},
});
Use case 3 — Global 401 handling
const client = new StrapiClient({
baseURL: 'https://api.example.com',
onError: async (error) => {
if (isStrapiErrorOf(error, 'UnauthorizedError')) {
await clearStoredToken();
// redirect to login, dispatch logout action, etc.
}
throw error; // always re-throw
},
});
Integration in BaseAPI.request
protected async request<R>(
url: string,
options: RequestInit = {},
nextOptions?: NextOptions,
errorPrefix = 'Strapi API'
): Promise<R> {
// Build headers as today...
// 1. onRequest — modify config before sending
let requestConfig: RequestConfig = {
url,
method: options.method ?? 'GET',
headers,
body: options.body as any,
};
if (this.config.onRequest) {
requestConfig = await this.config.onRequest(requestConfig);
url = requestConfig.url;
options.body = requestConfig.body;
Object.assign(headers, requestConfig.headers);
}
let response: Response;
try {
response = await fetchFn(url, fetchOptions);
} catch (networkError) {
const connError = new StrapiConnectionError(...);
if (this.config.onError) {
await this.config.onError(connError, requestConfig);
}
throw connError;
}
// 2. onResponse — inspect/transform before parsing body
if (response.ok && this.config.onResponse) {
response = await this.config.onResponse(response, requestConfig);
}
if (!response.ok) {
const strapiError = new StrapiError(...);
if (this.config.onError) {
await this.config.onError(strapiError, requestConfig);
}
throw strapiError;
}
// parse and return as today...
}
3. Per-Collection Type Files
Current output
The generator emits two files regardless of schema size:
generated/
├── types.ts // 4 000 – 10 000+ lines: all entities, inputs, filters,
│ // payloads, populate params, components — everything
└── client.ts
As the schema grows types.ts becomes difficult to navigate, produces large
all-or-nothing diffs on any schema change, and puts the full type resolution
burden on the TypeScript language server for every file that imports even a
single type.
Proposed output structure
generated/
├── index.ts // barrel re-export — fully backwards compatible
├── client.ts // unchanged, imports from per-collection files
├── base.ts // shared primitives: StrapiID, RelationInput,
│ // MediaFile, BlocksContent, filter operators, etc.
└── collections/
├── {collection-a}.ts // Entity, EntityInput, EntityFilters,
├── {collection-b}.ts // EntityPopulateParam, EntityGetPayload
├── {collection-c}.ts
├── ... // one file per collection type and single type
└── components/
├── {group-a}.ts // component interfaces grouped by Strapi namespace
└── {group-b}.ts
index.ts — zero breaking changes
// generated/index.ts
export * from './base';
export * from './collections/collection-a';
export * from './collections/collection-b';
// ... one line per collection
All existing imports that reference "./generated/types" continue to work
without any changes. Consumers can optionally start importing directly from
per-collection files for faster local type resolution.
What each collection file contains
// collections/{collection}.ts
import type { RelationInput, MediaFile } from '../base';
import type { OtherEntityGetPayload, OtherEntityFilters } from './other-entity';
// 1. Entity read type
export interface Entity { ... }
// 2. Input type (create / update)
export interface EntityInput { ... }
// 3. Filter type
export interface EntityFilters extends LogicalOperators<EntityFilters> { ... }
// 4. Populate param
export type EntityPopulateParam = { ... }
// 5. GetPayload with populate inference
export type EntityGetPayload<P extends { ... }> = ...
Benefits
IDE performance — the TypeScript language server loads only the files
relevant to the currently open file. Editing a screen that uses one collection
no longer causes the LSP to parse thousands of lines of unrelated types.
Faster incremental builds — when only one collection's schema changes, only
its file is rewritten. All other files are untouched and the TypeScript
incremental cache (tsconfig.tsbuildinfo) stays valid.
Readable diffs — a schema change to a single collection produces a diff in
that collection's file only, not buried in a 10 000-line file with no context.
Monorepo / CODEOWNERS — per-collection files allow file-level ownership rules:
# .github/CODEOWNERS
generated/collections/collection-a.ts @team-a
generated/collections/collection-b.ts @team-b
Proposed CLI flag
# Default — existing single-file behaviour (non-breaking)
strapi-typed-client generate
# Opt in to per-collection splitting
strapi-typed-client generate --split-types
Once the pattern is validated --split-types could become the default in a
future major version, with --single-file as the opt-out.
Summary
| # |
Change |
Breaking |
Effort |
| 1.1 |
upload.destroy → upload.delete (open to discuss) |
⚠️ If renamed |
Trivial |
| 1.2 |
auth alias alongside authentication (open to discuss) |
No — additive alias |
Trivial |
| 2 |
onRequest / onResponse / onError interceptors |
No — additive config |
Low |
| 3 |
Per-collection type files (--split-types flag) |
No — barrel keeps all imports valid |
Medium |
[Feature Request] Naming consistency, request interceptors, and per-collection type files
Overview
Three related improvements to the generated client and generator output:
upload.destroyvsdelete,authenticationvsauthmanagement, logging, and global error handling
types.tsinto one file percollection for better IDE performance, faster incremental builds, and readable diffs
1. Naming Consistency
1.1
upload.destroyvsupload.deleteUploadAPIis the only API across the entire generated client that usesdestroyinstead of
delete. EveryCollectionAPIusesdelete:destroyis the internal Strapi controller action name(
plugin::upload.controllers.content-api.destroy) and arguably should not surface inthe public client API, which otherwise has no awareness of internal controller naming.
Open for discussion: whether
deleteis the right name, or whether there is areason to keep
destroy(e.g. to signal that the operation is permanent and removesthe file from storage, not just a soft delete).
1.2
authenticationvsauthThe auth namespace is the only property on
StrapiClientthat uses a long-form name.Most SDKs and clients in the ecosystem use the shorter
auth:Open for discussion: whether
authenticationshould be replaced byauth,kept alongside it, or left as-is. If renamed, keeping
authenticationas anon-breaking alias is straightforward:
This way existing consumers continue to work without changes while new code uses
the shorter form.
2. Request/Response Interceptors
Interceptors allow consumers to hook into the request lifecycle without subclassing
or patching the client. This is the most commonly requested pattern across HTTP
client libraries and covers use cases that cannot be solved at the call site.
Proposed
StrapiClientConfigadditionsUse case 1 — Dynamic token injection (most common)
The primary motivation: reading the token from any async source at request time
rather than managing it imperatively via
setToken().This eliminates the need to call
setToken()on login/logout/restore and removesany possibility of the client holding a stale token. The token is read from the
authoritative source on every request.
Use case 2 — Logging and metrics
Use case 3 — Global 401 handling
Integration in
BaseAPI.request3. Per-Collection Type Files
Current output
The generator emits two files regardless of schema size:
As the schema grows
types.tsbecomes difficult to navigate, produces largeall-or-nothing diffs on any schema change, and puts the full type resolution
burden on the TypeScript language server for every file that imports even a
single type.
Proposed output structure
index.ts— zero breaking changesAll existing imports that reference
"./generated/types"continue to workwithout any changes. Consumers can optionally start importing directly from
per-collection files for faster local type resolution.
What each collection file contains
Benefits
IDE performance — the TypeScript language server loads only the files
relevant to the currently open file. Editing a screen that uses one collection
no longer causes the LSP to parse thousands of lines of unrelated types.
Faster incremental builds — when only one collection's schema changes, only
its file is rewritten. All other files are untouched and the TypeScript
incremental cache (
tsconfig.tsbuildinfo) stays valid.Readable diffs — a schema change to a single collection produces a diff in
that collection's file only, not buried in a 10 000-line file with no context.
Monorepo / CODEOWNERS — per-collection files allow file-level ownership rules:
Proposed CLI flag
Once the pattern is validated
--split-typescould become the default in afuture major version, with
--single-fileas the opt-out.Summary
upload.destroy→upload.delete(open to discuss)authalias alongsideauthentication(open to discuss)onRequest/onResponse/onErrorinterceptors--split-typesflag)