From 0d18e79bcccc4b0518f5dc7b7ea8cee29197304c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:17:54 +0000 Subject: [PATCH 1/5] enhance(rest): Type process() params in RestEndpoint.extend() process(value, params) passed to RestEndpoint.extend() or resource().extend() typed params as any. Type them from the extended endpoint's path, searchParams and body, including a path set in the same call. Endpoints callable several ways (optional params or body) merge their argument lists position-wise, so every way the endpoint is called type-checks. extend() options are now constrained by ExtendableRestGenerics (PartialRestGenerics without process), so TypeScript 4.1-5.x contextually type process() instead of reporting implicit any. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bv3zm61qvR8ytLLib2KVq3 --- .../references/RestEndpoint.md | 14 +++ .../references/RestEndpoint.vue.md | 14 +++ .../references/RestEndpoint.md | 14 +++ .../references/RestEndpoint.vue.md | 14 +++ .changeset/extend-process-params.md | 24 ++++ docs/rest/api/RestEndpoint.md | 14 +++ .../src-4.1-types/resourceExtendable.d.ts | 6 +- packages/rest/src/RestEndpointTypes.ts | 40 ++++++- packages/rest/src/resourceExtendable.ts | 5 +- .../typescript-tests/extendProcess.test.ts | 108 ++++++++++++++++++ website/blog/2026-10-03-v0.19-batch-set.md | 30 +++++ 11 files changed, 272 insertions(+), 11 deletions(-) create mode 100644 .changeset/extend-process-params.md create mode 100644 packages/rest/typescript-tests/extendProcess.test.ts diff --git a/.agents/skills/data-client-rest-setup/references/RestEndpoint.md b/.agents/skills/data-client-rest-setup/references/RestEndpoint.md index bef749d6e9e8..961f3269bc37 100644 --- a/.agents/skills/data-client-rest-setup/references/RestEndpoint.md +++ b/.agents/skills/data-client-rest-setup/references/RestEndpoint.md @@ -778,6 +778,20 @@ Override this for advanced cases like extracting headers alongside the body. Perform any transforms with the parsed result. Defaults to identity function (do nothing). +`args` are the arguments the endpoint was called with. In [extend()](#extend), they are typed from the +resulting endpoint's [path](#path), [searchParams](#searchParams) and [body](#body). + +```ts +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const getUserWithId = getUser.extend({ + process(value, params) { + // params is { id: string | number } + return { ...value, id: `${params.id}` }; + }, +}); +``` + > **Tip** > > The return type of process can be used to set the return type of the endpoint fetch: diff --git a/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md b/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md index cf3111648e47..3a28e823cff3 100644 --- a/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md +++ b/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md @@ -778,6 +778,20 @@ Override this for advanced cases like extracting headers alongside the body. Perform any transforms with the parsed result. Defaults to identity function (do nothing). +`args` are the arguments the endpoint was called with. In [extend()](#extend), they are typed from the +resulting endpoint's [path](#path), [searchParams](#searchParams) and [body](#body). + +```ts +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const getUserWithId = getUser.extend({ + process(value, params) { + // params is { id: string | number } + return { ...value, id: `${params.id}` }; + }, +}); +``` + > **Tip** > > The return type of process can be used to set the return type of the endpoint fetch: diff --git a/.agents/skills/data-client-rest/references/RestEndpoint.md b/.agents/skills/data-client-rest/references/RestEndpoint.md index 1c8f5fcbd064..f9dad95531d0 100644 --- a/.agents/skills/data-client-rest/references/RestEndpoint.md +++ b/.agents/skills/data-client-rest/references/RestEndpoint.md @@ -778,6 +778,20 @@ Override this for advanced cases like extracting headers alongside the body. Perform any transforms with the parsed result. Defaults to identity function (do nothing). +`args` are the arguments the endpoint was called with. In [extend()](#extend), they are typed from the +resulting endpoint's [path](#path), [searchParams](#searchParams) and [body](#body). + +```ts +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const getUserWithId = getUser.extend({ + process(value, params) { + // params is { id: string | number } + return { ...value, id: `${params.id}` }; + }, +}); +``` + > **Tip** > > The return type of process can be used to set the return type of the endpoint fetch: diff --git a/.agents/skills/data-client-rest/references/RestEndpoint.vue.md b/.agents/skills/data-client-rest/references/RestEndpoint.vue.md index 68aaab5ec636..f99a4efc796b 100644 --- a/.agents/skills/data-client-rest/references/RestEndpoint.vue.md +++ b/.agents/skills/data-client-rest/references/RestEndpoint.vue.md @@ -778,6 +778,20 @@ Override this for advanced cases like extracting headers alongside the body. Perform any transforms with the parsed result. Defaults to identity function (do nothing). +`args` are the arguments the endpoint was called with. In [extend()](#extend), they are typed from the +resulting endpoint's [path](#path), [searchParams](#searchParams) and [body](#body). + +```ts +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const getUserWithId = getUser.extend({ + process(value, params) { + // params is { id: string | number } + return { ...value, id: `${params.id}` }; + }, +}); +``` + > **Tip** > > The return type of process can be used to set the return type of the endpoint fetch: diff --git a/.changeset/extend-process-params.md b/.changeset/extend-process-params.md new file mode 100644 index 000000000000..98900598d044 --- /dev/null +++ b/.changeset/extend-process-params.md @@ -0,0 +1,24 @@ +--- +'@data-client/rest': patch +--- + +Type the `params` and `body` that `process()` receives in `.extend()` + +A `process(value, params)` method passed to `RestEndpoint.extend()` or `resource().extend()` used to get `params` typed as `any`, so a typo or a wrong assumption about a path parameter went unnoticed until runtime. They are now typed from the extended endpoint's `path`, `searchParams` and `body`, including a `path` set in the same `.extend()` call. + +```ts +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const getUserById = getUser.extend({ + path: '/users/by-id/:userId', + process(value, params) { + params.userId; // string | number + params.id; // TypeScript error: 'id' is not a param of '/users/by-id/:userId' + return value; + }, +}); +``` + +Endpoints whose params are optional pass `params` as possibly `undefined`, so read it with `params?.page`. + +On TypeScript 5.x and earlier, `process(value, params)` in an `.extend()` that also sets `path` no longer fails with "implicitly has an 'any' type" under `strict`. diff --git a/docs/rest/api/RestEndpoint.md b/docs/rest/api/RestEndpoint.md index 203cf2758205..56825d6353dd 100644 --- a/docs/rest/api/RestEndpoint.md +++ b/docs/rest/api/RestEndpoint.md @@ -851,6 +851,20 @@ Override this for advanced cases like extracting headers alongside the body. Perform any transforms with the parsed result. Defaults to identity function (do nothing). +`args` are the arguments the endpoint was called with. In [extend()](#extend), they are typed from the +resulting endpoint's [path](#path), [searchParams](#searchParams) and [body](#body). + +```ts +const getUser = new RestEndpoint({ path: '/users/:id' }); + +const getUserWithId = getUser.extend({ + process(value, params) { + // params is { id: string | number } + return { ...value, id: `${params.id}` }; + }, +}); +``` + :::tip The return type of process can be used to set the return type of the endpoint fetch: diff --git a/packages/rest/src-4.1-types/resourceExtendable.d.ts b/packages/rest/src-4.1-types/resourceExtendable.d.ts index c32b27be8b93..aa00b3ea7a57 100644 --- a/packages/rest/src-4.1-types/resourceExtendable.d.ts +++ b/packages/rest/src-4.1-types/resourceExtendable.d.ts @@ -2,17 +2,17 @@ import type { EndpointInterface, EndpointToFunction } from '@data-client/endpoin import type { ResourcePath } from './pathTypes.js'; import type { ResourceExtension, ResourceEndpointExtensions, CustomResource, ExtendedResource } from './resourceExtensionTypes.js'; import type { ResourceGenerics, ResourceInterface } from './resourceTypes.js'; -import type { PartialRestGenerics, RestEndpointExtendOptions, RestExtendedEndpoint, RestInstanceBase } from './RestEndpoint.js'; +import type { ExtendableRestGenerics, PartialRestGenerics, RestEndpointExtendOptions, RestExtendedEndpoint, RestInstanceBase } from './RestEndpoint.js'; export interface Extendable { extend, 'extend'>, ExtendOptions extends PartialRestGenerics | {}>(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): ResourceExtension; + }, ExtendKey extends Exclude, 'extend'>, ExtendOptions extends ExtendableRestGenerics | {}>(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): ResourceExtension; extend(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): R & { + }, ExtendKey extends string, ExtendOptions extends ExtendableRestGenerics | {}>(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): R & { [key in ExtendKey]: RestExtendedEndpoint; }; extend(this: R, options: ResourceEndpointExtensions): CustomResource; diff --git a/packages/rest/src/RestEndpointTypes.ts b/packages/rest/src/RestEndpointTypes.ts index c78888d23506..bc19590d8db0 100644 --- a/packages/rest/src/RestEndpointTypes.ts +++ b/packages/rest/src/RestEndpointTypes.ts @@ -40,7 +40,7 @@ type ContentSchemaGuard = * `E['fetch']` instead of `F` so it doesn't depend on the outer generics. */ interface RestInstanceExtenders { - // TODO: `ExtendOptions extends PartialRestGenerics | {}` is a hack for options with no + // TODO: `ExtendOptions extends ExtendableRestGenerics | {}` is a hack for options with no // PartialRestGenerics members. Overloads (like paginated) can't tell the cases apart // since every member is optional. /** Creates a child endpoint that inherits from this while overriding provided `options`. @@ -48,7 +48,7 @@ interface RestInstanceExtenders { */ extend< E extends RestInstanceBase, - ExtendOptions extends PartialRestGenerics | {}, + ExtendOptions extends ExtendableRestGenerics | {}, >( this: E, options: Readonly< @@ -272,7 +272,13 @@ export type RestEndpointExtendOptions< OptionsToFunction, 'schema' extends keyof O ? Extract : E['schema'] -> & +> & { + /** @see https://dataclient.io/rest/api/RestEndpoint#process */ + process?( + value: any, + ...args: ProcessArgs>> + ): any; +} & // Same as Partial>, but skips the per-key Exclude<> work // (and the double mapped type) when E has no members beyond the standard ones. // Keep the guard inside the mapped type's keys: a `? unknown : ...` conditional @@ -282,6 +288,23 @@ export type RestEndpointExtendOptions< keyof E extends ExtendOmitKeys ? never : Exclude >; +/** Parameters as a single tuple, so process() accepts every way the endpoint can be called. + * Endpoints with optional params or body have a union like `[params] | []` or `[params, body] | [body]`; + * it merges position-wise, with an element optional when some call omits it. + */ +type ProcessArgs = + number extends A['length'] ? A + : [A['length']] extends [0] ? A + : [A['length']] extends [1] ? A + : [A['length']] extends [2] ? A + : [] extends A ? + [ArgAt1] extends [never] ? + [params?: ArgAt0] + : [params?: ArgAt0, body?: ArgAt1] + : [params: ArgAt0, body?: ArgAt1]; +type ArgAt0 = A extends readonly [infer X, ...any[]] ? X : never; +type ArgAt1 = A extends readonly [any, infer X, ...any[]] ? X : never; + type ExtendOmitKeys = KeyofRestEndpoint | keyof PartialRestGenerics | keyof RestEndpointOptions; @@ -430,7 +453,10 @@ export type RestExtendedEndpoint< (keyof E extends KeyofRestEndpoint ? unknown : Omit); -export interface PartialRestGenerics { +/** PartialRestGenerics without `process`. extend() constrains its options to this, so the + * `process` member of RestEndpointExtendOptions is the only contextual type for process() params. + */ +export interface ExtendableRestGenerics { /** @see https://dataclient.io/rest/api/RestEndpoint#path */ readonly path?: string; /** @see https://dataclient.io/rest/api/RestEndpoint#schema */ @@ -445,11 +471,13 @@ export interface PartialRestGenerics { searchParams?: any; /** @see https://dataclient.io/rest/api/RestEndpoint#paginationfield */ readonly paginationField?: string; - /** @see https://dataclient.io/rest/api/RestEndpoint#process */ - process?(value: any, ...args: any): any; /** @see https://dataclient.io/rest/api/RestEndpoint#content */ readonly content?: ContentType; } +export interface PartialRestGenerics extends ExtendableRestGenerics { + /** @see https://dataclient.io/rest/api/RestEndpoint#process */ + process?(value: any, ...args: any): any; +} /** Generic types when constructing a RestEndpoint * * @see https://dataclient.io/rest/api/RestEndpoint#inheritance diff --git a/packages/rest/src/resourceExtendable.ts b/packages/rest/src/resourceExtendable.ts index 077c1fba857d..3fccc3796b81 100644 --- a/packages/rest/src/resourceExtendable.ts +++ b/packages/rest/src/resourceExtendable.ts @@ -16,6 +16,7 @@ import type { ResourceInterface, } from './resourceTypes.js'; import type { + ExtendableRestGenerics, PartialRestGenerics, RestEndpointExtendOptions, RestExtendedEndpoint, @@ -35,7 +36,7 @@ export interface Extendable< }, const ExtendKey extends Exclude, 'extend'>, // TODO: see RestEndpoint.extend TODO - ExtendOptions extends PartialRestGenerics | {}, + ExtendOptions extends ExtendableRestGenerics | {}, >( this: R, key: ExtendKey, @@ -53,7 +54,7 @@ export interface Extendable< R extends { get: RestInstanceBase }, const ExtendKey extends string, // TODO: see RestEndpoint.extend TODO - ExtendOptions extends PartialRestGenerics | {}, + ExtendOptions extends ExtendableRestGenerics | {}, >( this: R, key: ExtendKey, diff --git a/packages/rest/typescript-tests/extendProcess.test.ts b/packages/rest/typescript-tests/extendProcess.test.ts new file mode 100644 index 000000000000..f72f5ae91f54 --- /dev/null +++ b/packages/rest/typescript-tests/extendProcess.test.ts @@ -0,0 +1,108 @@ +// Type-level regression tests for the params passed to process() in RestEndpoint.extend(). +// Each @ts-expect-error line must keep erroring; plain lines must keep compiling. +import { Entity, RestEndpoint, resource } from '@data-client/rest'; + +const get = new RestEndpoint({ path: '/users/:id' }); + +export const child = get.extend({ + process(value, params) { + // @ts-expect-error id is string | number + return params.id.toFixed(); + }, +}); +child({ id: 'bob' }); + +export const ok = get.extend({ + process(value, params) { + const s: string | number = params.id; + return s; + }, +}); + +export const annotated = get.extend({ + process(value: any, params: { id: string }) { + return params.id; + }, +}); + +export const chained = get.extend({ path: '/u/:uid' }).extend({ + process(value, params) { + const s: string | number = params.uid; + // @ts-expect-error not a param + params.id; + return s; + }, +}); + +export const samecall = get.extend({ + path: '/x/:xid', + process(value, params) { + const s: string | number = params.xid; + // @ts-expect-error old path param is gone + params.id; + return s; + }, +}); + +export const withSearch = new RestEndpoint({ + path: '/users', + searchParams: {} as { page?: number }, +}).extend({ + process(value, params) { + const p: number | undefined = params?.page; + // @ts-expect-error page is a number + params?.page?.toUpperCase(); + return p; + }, +}); + +export const post = new RestEndpoint({ + path: '/users/:id', + method: 'POST', +}).extend({ + process(value, params, body) { + // @ts-expect-error id is string | number + params.id.toFixed(); + return [params.id, body]; + }, +}); + +// optional searchParams with a body: called as ep(body) or ep(params, body) +export const postSearch = new RestEndpoint({ + path: '/users', + method: 'POST', + searchParams: {} as { page?: number }, + body: {} as { name: string }, +}).extend({ + process(value, params, body) { + // params is the body when called as ep(body) + const p: { page?: number } | { name: string } = params; + const b: { name: string } | undefined = body; + // @ts-expect-error page may be missing + params.page; + return [p, b]; + }, +}); + +class User extends Entity { + id = ''; + static key = 'User'; +} +const UserResource = resource({ path: '/users/:id', schema: User }); + +export const ExtendedUser = UserResource.extend('get', { + process(value, params) { + const s: string | number = params.id; + // @ts-expect-error id is string | number + params.id.toFixed(); + return [value, s]; + }, +}).extend('byName', { + path: '/users/name/:name', + process(value, params) { + const s: string | number = params.name; + // @ts-expect-error not a param of byName + params.id; + return [value, s]; + }, +}); diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index fd0231eb79f7..f44283d08b41 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -22,6 +22,7 @@ endpoint code about 2x faster, and fixes a round of TypeScript and Vue issues. - Fix TypeScript 7 module resolution for package `exports`; imports now resolve to declaration files ([#4019](https://github.com/reactive/data-client/pull/4019)) - [renderDataHook()](/docs/api/renderDataHook) runs provider mount effects when the first render suspends ([#4099](https://github.com/reactive/data-client/pull/4099)) - [Controller.set() values are typed by the schema](/blog/2026/10/03/v0.19-batch-set#typed-set), so `ctrl.set(new schema.All(Todo), 42)` is a TypeScript error ([#4133](https://github.com/reactive/data-client/pull/4133)) +- [process() in .extend() gets typed params](/blog/2026/10/03/v0.19-batch-set#extend-process-params), so a wrong path parameter is a TypeScript error instead of `undefined` at runtime (PRLINK) - Vue [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) keep the previous data while new arguments load, instead of returning `undefined` ([#4131](https://github.com/reactive/data-client/pull/4131)) - Vue [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) send fetch errors after arguments change to `onErrorCaptured()` instead of an unhandled promise rejection ([#4135](https://github.com/reactive/data-client/pull/4135)) - Vue [useSuspense()](/vue/api/useSuspense), [useDLE()](/vue/api/useDLE) and [useFetch()](/vue/api/useFetch) no longer refetch stale data on every store update, so a `controller.set()` is not overwritten ([#4134](https://github.com/reactive/data-client/pull/4134)) @@ -353,6 +354,35 @@ against the combined fields of all its members, so a row mixing fields from diff If code that previously compiled now fails here, it was writing data its schema doesn't describe. Fix the value, or widen the Entity's field types if the data really can take that shape. +### Typed process() params in extend() {#extend-process-params} + +A [process()](/rest/api/RestEndpoint#process) method passed to [.extend()](/rest/api/RestEndpoint#extend) got +`params` typed as `any`, so reading a parameter the endpoint doesn't have compiled and returned `undefined` at +runtime. `params` and `body` are now typed from the extended endpoint, including a `path` set in the same call +(PRLINK): + + + +```ts +import { RestEndpoint } from '@data-client/rest'; + +const getUser = new RestEndpoint({ path: '/users/:id' }); + +export const getUserById = getUser.extend({ + path: '/users/by-id/:userId', + process(value, params) { + const userId: string | number = params.userId; + // @ts-expect-error 'id' is not a param of '/users/by-id/:userId' + params.id; + return { ...value, userId }; + }, +}); +``` + + + +When every param is optional, `params` may be `undefined`, so read it with `params?.page`. + ### Vue getter arguments {#vue-getter-args} Vue composables like [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) were typed to accept a From 62e3ae70b05e797644c05c44560da6b4cee6506b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:18:36 +0000 Subject: [PATCH 2/5] docs(blog): Link #4183 in v0.19 process() params entry Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bv3zm61qvR8ytLLib2KVq3 --- website/blog/2026-10-03-v0.19-batch-set.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index f44283d08b41..1a7460ac185f 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -22,7 +22,7 @@ endpoint code about 2x faster, and fixes a round of TypeScript and Vue issues. - Fix TypeScript 7 module resolution for package `exports`; imports now resolve to declaration files ([#4019](https://github.com/reactive/data-client/pull/4019)) - [renderDataHook()](/docs/api/renderDataHook) runs provider mount effects when the first render suspends ([#4099](https://github.com/reactive/data-client/pull/4099)) - [Controller.set() values are typed by the schema](/blog/2026/10/03/v0.19-batch-set#typed-set), so `ctrl.set(new schema.All(Todo), 42)` is a TypeScript error ([#4133](https://github.com/reactive/data-client/pull/4133)) -- [process() in .extend() gets typed params](/blog/2026/10/03/v0.19-batch-set#extend-process-params), so a wrong path parameter is a TypeScript error instead of `undefined` at runtime (PRLINK) +- [process() in .extend() gets typed params](/blog/2026/10/03/v0.19-batch-set#extend-process-params), so a wrong path parameter is a TypeScript error instead of `undefined` at runtime ([#4183](https://github.com/reactive/data-client/pull/4183)) - Vue [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) keep the previous data while new arguments load, instead of returning `undefined` ([#4131](https://github.com/reactive/data-client/pull/4131)) - Vue [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) send fetch errors after arguments change to `onErrorCaptured()` instead of an unhandled promise rejection ([#4135](https://github.com/reactive/data-client/pull/4135)) - Vue [useSuspense()](/vue/api/useSuspense), [useDLE()](/vue/api/useDLE) and [useFetch()](/vue/api/useFetch) no longer refetch stale data on every store update, so a `controller.set()` is not overwritten ([#4134](https://github.com/reactive/data-client/pull/4134)) @@ -359,7 +359,7 @@ or widen the Entity's field types if the data really can take that shape. A [process()](/rest/api/RestEndpoint#process) method passed to [.extend()](/rest/api/RestEndpoint#extend) got `params` typed as `any`, so reading a parameter the endpoint doesn't have compiled and returned `undefined` at runtime. `params` and `body` are now typed from the extended endpoint, including a `path` set in the same call -(PRLINK): +([#4183](https://github.com/reactive/data-client/pull/4183)): From e43d8549967beedaa4f93fa3960e6b14c2bde83f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:25:52 +0000 Subject: [PATCH 3/5] enhance(rest): Note new type errors in changeset; document ProcessArgs scope Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bv3zm61qvR8ytLLib2KVq3 --- .changeset/extend-process-params.md | 4 +++- packages/rest/src/RestEndpointTypes.ts | 5 ++++- website/blog/2026-10-03-v0.19-batch-set.md | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.changeset/extend-process-params.md b/.changeset/extend-process-params.md index 98900598d044..1957f4c3b373 100644 --- a/.changeset/extend-process-params.md +++ b/.changeset/extend-process-params.md @@ -4,7 +4,7 @@ Type the `params` and `body` that `process()` receives in `.extend()` -A `process(value, params)` method passed to `RestEndpoint.extend()` or `resource().extend()` used to get `params` typed as `any`, so a typo or a wrong assumption about a path parameter went unnoticed until runtime. They are now typed from the extended endpoint's `path`, `searchParams` and `body`, including a `path` set in the same `.extend()` call. +A `process(value, params)` method passed to `RestEndpoint.extend()` or `resource().extend('get', {...})` used to get `params` typed as `any`, so a typo or a wrong assumption about a path parameter went unnoticed until runtime. They are now typed from the extended endpoint's `path`, `searchParams` and `body`, including a `path` set in the same `.extend()` call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); @@ -21,4 +21,6 @@ const getUserById = getUser.extend({ Endpoints whose params are optional pass `params` as possibly `undefined`, so read it with `params?.page`. +This can surface new TypeScript errors in existing `process()` methods that read a param the endpoint doesn't have, or that read optional `params` without a check. Each is a read that could be `undefined` or throw at runtime, so fix the param name or add the check. + On TypeScript 5.x and earlier, `process(value, params)` in an `.extend()` that also sets `path` no longer fails with "implicitly has an 'any' type" under `strict`. diff --git a/packages/rest/src/RestEndpointTypes.ts b/packages/rest/src/RestEndpointTypes.ts index bc19590d8db0..3ad9fa7ca754 100644 --- a/packages/rest/src/RestEndpointTypes.ts +++ b/packages/rest/src/RestEndpointTypes.ts @@ -289,8 +289,11 @@ export type RestEndpointExtendOptions< >; /** Parameters as a single tuple, so process() accepts every way the endpoint can be called. - * Endpoints with optional params or body have a union like `[params] | []` or `[params, body] | [body]`; + * Endpoints with optional params or body have a union like `[params] | []` or `[params, body] | [body]` + * (see ParamFetchNoBody/ParamFetchWithBody); * it merges position-wise, with an element optional when some call omits it. + * Only for contextually typing an options callback: TypeScript can't infer callback parameters from a + * union of tuples. The instance `process()` keeps `Parameters`, the stricter signature for callers. */ type ProcessArgs = number extends A['length'] ? A diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index 1a7460ac185f..8205487a01d0 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -383,6 +383,9 @@ export const getUserById = getUser.extend({ When every param is optional, `params` may be `undefined`, so read it with `params?.page`. +This can surface new TypeScript errors in existing `process()` methods. Each one marks a read that could be `undefined` +or throw at runtime, so fix the param name or add the missing check. + ### Vue getter arguments {#vue-getter-args} Vue composables like [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) were typed to accept a From 80c27436f38657c9e3d432490c29783738b8a40a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:03:36 +0000 Subject: [PATCH 4/5] fix(rest): Keep optional fetch param types in extend() process() A custom fetch with an optional params or body argument (e.g. a defaulted `params = {}`) gave process() `never` for that argument. Read tuple elements with optional-element patterns so their types are kept. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bv3zm61qvR8ytLLib2KVq3 --- packages/rest/src/RestEndpointTypes.ts | 15 ++++++- .../typescript-tests/extendProcess.test.ts | 44 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/RestEndpointTypes.ts b/packages/rest/src/RestEndpointTypes.ts index 3ad9fa7ca754..1881150ac3e3 100644 --- a/packages/rest/src/RestEndpointTypes.ts +++ b/packages/rest/src/RestEndpointTypes.ts @@ -305,8 +305,19 @@ type ProcessArgs = [params?: ArgAt0] : [params?: ArgAt0, body?: ArgAt1] : [params: ArgAt0, body?: ArgAt1]; -type ArgAt0 = A extends readonly [infer X, ...any[]] ? X : never; -type ArgAt1 = A extends readonly [any, infer X, ...any[]] ? X : never; +// Optional-element patterns, so a custom `fetch(params?)` keeps its element types +type ArgAt0 = + A extends readonly [any?, ...any[]] ? + A['length'] extends 0 ? + never + : A[0] + : never; +type ArgAt1 = + A extends readonly [any?, any?, ...any[]] ? + A['length'] extends 0 | 1 ? + never + : A[1] + : never; type ExtendOmitKeys = KeyofRestEndpoint | keyof PartialRestGenerics | keyof RestEndpointOptions; diff --git a/packages/rest/typescript-tests/extendProcess.test.ts b/packages/rest/typescript-tests/extendProcess.test.ts index f72f5ae91f54..cad776de128d 100644 --- a/packages/rest/typescript-tests/extendProcess.test.ts +++ b/packages/rest/typescript-tests/extendProcess.test.ts @@ -106,3 +106,47 @@ export const ExtendedUser = UserResource.extend('get', { return [value, s]; }, }); + +// custom fetch with an optional (defaulted) params argument +class PageEndpoint extends RestEndpoint<{ + path: '/users'; + searchParams: { page?: number }; +}> { + fetch = async (params: { page?: number } = {}) => this.process([], params); +} +const pageEp = new PageEndpoint({ + path: '/users', + searchParams: {} as { page?: number }, +}); +export const pageChild = pageEp.extend({ + process(value, params) { + // @ts-expect-error page is a number + params?.page?.toUpperCase(); + return params?.page ?? value; + }, +}); +pageChild(); +pageChild({ page: 2 }); + +// custom fetch with an optional body argument +class OptionalBodyEndpoint extends RestEndpoint<{ + path: '/users/:id'; + method: 'POST'; + body: { name: string }; +}> { + fetch = async (params: { id: string | number }, body?: { name: string }) => + this.process([], params, body!); +} +export const optionalBodyChild = new OptionalBodyEndpoint({ + path: '/users/:id', + method: 'POST', + body: {} as { name: string }, +}).extend({ + process(value, params, body) { + const id: string | number = params.id; + const name: string | undefined = body?.name; + // @ts-expect-error body may be undefined + body.name; + return [id, name]; + }, +}); From bdd863778051a1323d33f1856e5a1db2c395cae8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:29:34 +0000 Subject: [PATCH 5/5] enhance(rest): Keep single custom fetch signatures as is in extend() process() Only merge Parameters when it is a union of call shapes, so a custom fetch with optional or extra arguments keeps its exact tuple. Simplify ArgAt0/ArgAt1 distribution. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Bv3zm61qvR8ytLLib2KVq3 --- packages/rest/src/RestEndpointTypes.ts | 22 +++++++++++++------ .../typescript-tests/extendProcess.test.ts | 19 ++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/rest/src/RestEndpointTypes.ts b/packages/rest/src/RestEndpointTypes.ts index 1881150ac3e3..3341c76771c0 100644 --- a/packages/rest/src/RestEndpointTypes.ts +++ b/packages/rest/src/RestEndpointTypes.ts @@ -290,30 +290,38 @@ export type RestEndpointExtendOptions< /** Parameters as a single tuple, so process() accepts every way the endpoint can be called. * Endpoints with optional params or body have a union like `[params] | []` or `[params, body] | [body]` - * (see ParamFetchNoBody/ParamFetchWithBody); - * it merges position-wise, with an element optional when some call omits it. + * (see ParamFetchNoBody/ParamFetchWithBody); it merges position-wise, with an element optional when + * some call omits it. A single tuple (like a custom `fetch(params?)`) is kept as is. * Only for contextually typing an options callback: TypeScript can't infer callback parameters from a * union of tuples. The instance `process()` keeps `Parameters`, the stricter signature for callers. */ type ProcessArgs = + // fast path for fixed-length tuples; [A['length']] can't be checked against a union of lengths number extends A['length'] ? A : [A['length']] extends [0] ? A : [A['length']] extends [1] ? A : [A['length']] extends [2] ? A + : IsUnion extends false ? A : [] extends A ? [ArgAt1] extends [never] ? [params?: ArgAt0] : [params?: ArgAt0, body?: ArgAt1] : [params: ArgAt0, body?: ArgAt1]; -// Optional-element patterns, so a custom `fetch(params?)` keeps its element types -type ArgAt0 = - A extends readonly [any?, ...any[]] ? +type IsUnion = + T extends any ? + [U] extends [T] ? + false + : true + : never; +// Distribute over the union; the length check gives never for a position a call omits +type ArgAt0 = + A extends unknown ? A['length'] extends 0 ? never : A[0] : never; -type ArgAt1 = - A extends readonly [any?, any?, ...any[]] ? +type ArgAt1 = + A extends unknown ? A['length'] extends 0 | 1 ? never : A[1] diff --git a/packages/rest/typescript-tests/extendProcess.test.ts b/packages/rest/typescript-tests/extendProcess.test.ts index cad776de128d..f1f6a6aea434 100644 --- a/packages/rest/typescript-tests/extendProcess.test.ts +++ b/packages/rest/typescript-tests/extendProcess.test.ts @@ -150,3 +150,22 @@ export const optionalBodyChild = new OptionalBodyEndpoint({ return [id, name]; }, }); + +// a single custom fetch signature is kept as is, including a third argument +class ExtraArgEndpoint extends RestEndpoint<{ path: '/users/:id' }> { + fetch = async ( + params: { id: string | number }, + body?: undefined, + extra?: number, + ) => this.process([], params); +} +export const extraArgChild = new ExtraArgEndpoint({ + path: '/users/:id', +}).extend({ + process(value, params, body, extra) { + const e: number | undefined = extra; + // @ts-expect-error extra may be undefined + extra.toFixed(); + return [params.id, e]; + }, +});