diff --git a/.agents/skills/data-client-rest-setup/references/RestEndpoint.md b/.agents/skills/data-client-rest-setup/references/RestEndpoint.md index f787625296b1..d7d293054d49 100644 --- a/.agents/skills/data-client-rest-setup/references/RestEndpoint.md +++ b/.agents/skills/data-client-rest-setup/references/RestEndpoint.md @@ -778,8 +778,8 @@ 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). +`args` are the arguments the endpoint was called with. They are typed from the endpoint's [path](#path), +[searchParams](#searchParams) and [body](#body), including those set in the same [extend()](#extend) call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); 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 86daf4d4baf4..25a212592c53 100644 --- a/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md +++ b/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md @@ -778,8 +778,8 @@ 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). +`args` are the arguments the endpoint was called with. They are typed from the endpoint's [path](#path), +[searchParams](#searchParams) and [body](#body), including those set in the same [extend()](#extend) call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); diff --git a/.agents/skills/data-client-rest/references/RestEndpoint.md b/.agents/skills/data-client-rest/references/RestEndpoint.md index def2076036ed..fe7256e03f0c 100644 --- a/.agents/skills/data-client-rest/references/RestEndpoint.md +++ b/.agents/skills/data-client-rest/references/RestEndpoint.md @@ -778,8 +778,8 @@ 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). +`args` are the arguments the endpoint was called with. They are typed from the endpoint's [path](#path), +[searchParams](#searchParams) and [body](#body), including those set in the same [extend()](#extend) call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); diff --git a/.agents/skills/data-client-rest/references/RestEndpoint.vue.md b/.agents/skills/data-client-rest/references/RestEndpoint.vue.md index ba46e7b661a4..a42827146643 100644 --- a/.agents/skills/data-client-rest/references/RestEndpoint.vue.md +++ b/.agents/skills/data-client-rest/references/RestEndpoint.vue.md @@ -778,8 +778,8 @@ 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). +`args` are the arguments the endpoint was called with. They are typed from the endpoint's [path](#path), +[searchParams](#searchParams) and [body](#body), including those set in the same [extend()](#extend) call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); diff --git a/.changeset/extend-process-params.md b/.changeset/extend-process-params.md index 1957f4c3b373..29b6af3714db 100644 --- a/.changeset/extend-process-params.md +++ b/.changeset/extend-process-params.md @@ -2,9 +2,9 @@ '@data-client/rest': patch --- -Type the `params` and `body` that `process()` receives in `.extend()` +Type the `params` and `body` that `process()` receives in `RestEndpoint` options -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. +A `process(value, params)` method passed to `new RestEndpoint()`, `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 endpoint's `path`, `searchParams` and `body`, including a `path` set in the same `.extend()` call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); diff --git a/.changeset/resource-extend-keeps-members.md b/.changeset/resource-extend-keeps-members.md new file mode 100644 index 000000000000..159bcaf420b7 --- /dev/null +++ b/.changeset/resource-extend-keeps-members.md @@ -0,0 +1,17 @@ +--- +'@data-client/rest': patch +--- + +Fix `resource().extend({ get: ... })` dropping endpoints added earlier from its type + +Customizing a resource's standard endpoints with `.extend({ ... })` kept every endpoint at runtime, but TypeScript lost the ones added before it with `.extend('name', options)`, and the deprecated `create`. Using them was a type error even though they worked. + +```ts +const UserResource = resource({ path: '/users/:id', schema: User }) + .extend('current', { path: '/user' }) + .extend({ get: { dataExpiryLength: 60000 } }); + +// Before: Property 'current' does not exist +// After: no error +const me = await ctrl.fetch(UserResource.current); +``` diff --git a/docs/rest/api/RestEndpoint.md b/docs/rest/api/RestEndpoint.md index c8a08aca8ff2..4005e9cd4be6 100644 --- a/docs/rest/api/RestEndpoint.md +++ b/docs/rest/api/RestEndpoint.md @@ -900,8 +900,8 @@ 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). +`args` are the arguments the endpoint was called with. They are typed from the endpoint's [path](#path), +[searchParams](#searchParams) and [body](#body), including those set in the same [extend()](#extend) call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); diff --git a/packages/rest/src-4.1-types/resourceExtendable.d.ts b/packages/rest/src-4.1-types/resourceExtendable.d.ts index aa00b3ea7a57..a9155575af6a 100644 --- a/packages/rest/src-4.1-types/resourceExtendable.d.ts +++ b/packages/rest/src-4.1-types/resourceExtendable.d.ts @@ -2,7 +2,7 @@ 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 { ExtendableRestGenerics, PartialRestGenerics, RestEndpointExtendOptions, RestExtendedEndpoint, RestInstanceBase } from './RestEndpoint.js'; +import type { ExtendableRestGenerics, RestEndpointExtendOptions, RestExtendedEndpoint, RestInstanceBase } from './RestEndpoint.js'; export interface Extendable(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): R & { [key in ExtendKey]: RestExtendedEndpoint; }; - extend(this: R, options: ResourceEndpointExtensions): CustomResource; + extend(this: R, options: ResourceEndpointExtensions): CustomResource & Omit; extend>(this: R, extender: (baseResource: R) => T): ExtendedResource; } //# sourceMappingURL=resourceExtendable.d.ts.map \ No newline at end of file diff --git a/packages/rest/src/RestEndpointTypes.ts b/packages/rest/src/RestEndpointTypes.ts index 3341c76771c0..aac2c9a4ac2b 100644 --- a/packages/rest/src/RestEndpointTypes.ts +++ b/packages/rest/src/RestEndpointTypes.ts @@ -272,13 +272,7 @@ 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 @@ -288,44 +282,13 @@ 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]` - * (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. +/** Argument I passed to process(), from Parameters. + * Indexing a union like `[params] | []` or `[params, body] | [body]` (see ParamFetchNoBody/ParamFetchWithBody) + * merges position-wise, giving `undefined` where some call omits the argument. + * Endpoints that take no args keep `any`, so process(value, params: any) and similar still compile. */ -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]; -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 unknown ? - A['length'] extends 0 | 1 ? - never - : A[1] - : never; +type ProcessArg = + [A['length']] extends [0] ? any : A[I]; type ExtendOmitKeys = KeyofRestEndpoint | keyof PartialRestGenerics | keyof RestEndpointOptions; @@ -475,8 +438,9 @@ export type RestExtendedEndpoint< (keyof E extends KeyofRestEndpoint ? unknown : Omit); -/** PartialRestGenerics without `process`. extend() constrains its options to this, so the - * `process` member of RestEndpointExtendOptions is the only contextual type for process() params. +/** PartialRestGenerics without `process`. Options passed to the constructor, extend() and + * resource().extend() are constrained by this, so RestEndpointOptions' typed `process` member is + * the only contextual type for process() params. */ export interface ExtendableRestGenerics { /** @see https://dataclient.io/rest/api/RestEndpoint#path */ @@ -507,6 +471,10 @@ export interface PartialRestGenerics extends ExtendableRestGenerics { export interface RestGenerics extends PartialRestGenerics { readonly path: string; } +/** RestGenerics without `process`; see ExtendableRestGenerics */ +interface ConstructorRestGenerics extends ExtendableRestGenerics { + readonly path: string; +} export type PaginationEndpoint< E extends FetchFunction & RestGenerics & { sideEffect?: boolean | undefined }, @@ -695,6 +663,15 @@ export interface RestEndpointOptions< fetch?: F; key?(...args: Parameters): string; url?(...args: Parameters): string; + /** @see https://dataclient.io/rest/api/RestEndpoint#process */ + // Positional rather than a rest tuple: when F is generic (like a path type parameter), + // a deferred rest tuple would reject process(value, params) for having too many params. + process?( + value: any, + params: ProcessArg, 0>, + body: ProcessArg, 1>, + ...rest: any[] + ): any; update?: EndpointUpdateFunction; } @@ -754,7 +731,7 @@ export interface RestEndpointConstructor { * * @see https://dataclient.io/rest/api/RestEndpoint */ - new ({ + new ({ method, sideEffect, name, diff --git a/packages/rest/src/resourceExtendable.ts b/packages/rest/src/resourceExtendable.ts index 3fccc3796b81..7c9f7f6738c6 100644 --- a/packages/rest/src/resourceExtendable.ts +++ b/packages/rest/src/resourceExtendable.ts @@ -17,7 +17,6 @@ import type { } from './resourceTypes.js'; import type { ExtendableRestGenerics, - PartialRestGenerics, RestEndpointExtendOptions, RestExtendedEndpoint, RestInstanceBase, @@ -72,11 +71,11 @@ export interface Extendable< }; extend< R extends ResourceInterface, - Get extends PartialRestGenerics = {}, - GetList extends PartialRestGenerics = {}, - Update extends PartialRestGenerics = {}, - PartialUpdate extends PartialRestGenerics = {}, - Delete extends PartialRestGenerics = {}, + Get extends ExtendableRestGenerics = {}, + GetList extends ExtendableRestGenerics = {}, + Update extends ExtendableRestGenerics = {}, + PartialUpdate extends ExtendableRestGenerics = {}, + Delete extends ExtendableRestGenerics = {}, >( this: R, options: ResourceEndpointExtensions< @@ -87,7 +86,9 @@ export interface Extendable< PartialUpdate, Delete >, - ): CustomResource; + ): CustomResource & + // keeps members like those added by extend('key', options) + Omit; extend< R extends ResourceInterface, T extends Record, diff --git a/packages/rest/src/resourceExtensionTypes.ts b/packages/rest/src/resourceExtensionTypes.ts index 9a6e224248b3..41d958ca25bb 100644 --- a/packages/rest/src/resourceExtensionTypes.ts +++ b/packages/rest/src/resourceExtensionTypes.ts @@ -8,6 +8,7 @@ import type { ResourcePath } from './pathTypes.js'; import { Extendable } from './resourceExtendable.js'; import { ResourceGenerics, ResourceInterface } from './resourceTypes.js'; import type { + ExtendableRestGenerics, PartialRestGenerics, RestExtendedEndpoint, RestInstanceBase, @@ -59,11 +60,11 @@ export type ExtendedResource< export interface ResourceEndpointExtensions< R extends ResourceInterface, - Get extends PartialRestGenerics = {}, - GetList extends PartialRestGenerics = {}, - Update extends PartialRestGenerics = {}, - PartialUpdate extends PartialRestGenerics = {}, - Delete extends PartialRestGenerics = {}, + Get extends ExtendableRestGenerics = {}, + GetList extends ExtendableRestGenerics = {}, + Update extends ExtendableRestGenerics = {}, + PartialUpdate extends ExtendableRestGenerics = {}, + Delete extends ExtendableRestGenerics = {}, > { readonly get?: RestEndpointOptions< unknown extends Get ? EndpointToFunction diff --git a/packages/rest/typescript-tests/extendOptions.test.ts b/packages/rest/typescript-tests/extendOptions.test.ts new file mode 100644 index 000000000000..2c3e49e595bd --- /dev/null +++ b/packages/rest/typescript-tests/extendOptions.test.ts @@ -0,0 +1,45 @@ +// Options passed to extend() are checked against their types, and +// resource().extend() keeps every member of the resource it extends. +// Each @ts-expect-error line must keep erroring; plain lines must keep compiling. +import { User } from '__tests__/new'; + +import resource from '../src/resource'; +import RestEndpoint from '../src/RestEndpoint'; + +const ep = new RestEndpoint({ path: '/users/:id', schema: User }); +const UserResource = resource({ path: '/users/:id', schema: User }); + +/* ---------------- option types (plain ep.extend() is in extendPathsGetPage) ---------------- */ +// @ts-expect-error +ep.extend({ path: '/u/:uid' }).extend({ dataExpiryLength: 'long' }); +// @ts-expect-error +ep.extend({ path: '/u/:uid', dataExpiryLength: 'long' }); +// @ts-expect-error +UserResource.get.extend({ dataExpiryLength: 'long' }); +// @ts-expect-error +UserResource.extend('get', { dataExpiryLength: 'long' }); +// @ts-expect-error +UserResource.extend('current', { dataExpiryLength: 'long' }); +// @ts-expect-error +UserResource.extend({ get: { dataExpiryLength: 'long' } }); + +/* ---------------- resource().extend() keeps members ---------------- */ +const CurrentUserResource = UserResource.extend('current', { + path: '/user', +}).extend({ get: { dataExpiryLength: 1000 } }); +export const current = CurrentUserResource.current(); +export const get = CurrentUserResource.get({ id: 5 }); +// deprecated, but still there at runtime +export const create = CurrentUserResource.create; +// @ts-expect-error not a member +CurrentUserResource.other; + +// extend({...}) results can be extended again +const Chained = CurrentUserResource.extend({ + update: { dataExpiryLength: 5 }, +}).extend('me', { path: '/me' }); +export const chained = [ + Chained.current(), + Chained.me(), + Chained.get({ id: 1 }), +]; diff --git a/packages/rest/typescript-tests/extendProcess.test.ts b/packages/rest/typescript-tests/extendProcess.test.ts index f1f6a6aea434..8cd8eae0a0ba 100644 --- a/packages/rest/typescript-tests/extendProcess.test.ts +++ b/packages/rest/typescript-tests/extendProcess.test.ts @@ -1,4 +1,5 @@ -// Type-level regression tests for the params passed to process() in RestEndpoint.extend(). +// Type-level regression tests for the params passed to process() in RestEndpoint options +// (constructor, .extend() and resource().extend()). // Each @ts-expect-error line must keep erroring; plain lines must keep compiling. import { Entity, RestEndpoint, resource } from '@data-client/rest'; @@ -151,7 +152,7 @@ export const optionalBodyChild = new OptionalBodyEndpoint({ }, }); -// a single custom fetch signature is kept as is, including a third argument +// a custom fetch's params are kept as is; arguments after body are untyped class ExtraArgEndpoint extends RestEndpoint<{ path: '/users/:id' }> { fetch = async ( params: { id: string | number }, @@ -163,9 +164,102 @@ export const extraArgChild = new ExtraArgEndpoint({ path: '/users/:id', }).extend({ process(value, params, body, extra) { + // @ts-expect-error id is string | number + params.id.toFixed(); const e: number | undefined = extra; - // @ts-expect-error extra may be undefined - extra.toFixed(); return [params.id, e]; }, }); + +/* ---------------- constructor ---------------- */ +export const constructed = new RestEndpoint({ + path: '/users/:id', + process(value, params) { + // @ts-expect-error id is string | number + params.id.toFixed(); + return `${params.id}`; + }, +}); +export const constructedResult: Promise = constructed({ id: 1 }); + +export const constructedPost = new RestEndpoint({ + path: '/users/:id', + method: 'POST', + body: {} as { name: string }, + process(value, params, body) { + // @ts-expect-error not in body + body.age; + return body.name; + }, +}); +export const constructedPostResult: Promise = constructedPost( + { id: 1 }, + { name: 'a' }, +); + +export const constructedSearch = new RestEndpoint({ + path: '/users', + searchParams: {} as { page?: number }, + process(value, params) { + // @ts-expect-error page is a number + params?.page?.toUpperCase(); + return params?.page; + }, +}); + +/* ---------------- resource().extend({ ... }) ---------------- */ +export const CustomUser = UserResource.extend({ + get: { + process(value, params) { + // @ts-expect-error id is string | number + params.id.toFixed(); + return value; + }, + }, + getList: { + path: '/groups/:group/users', + process(value, params) { + const g: string | number = params.group; + // @ts-expect-error not a param of getList's new path + params.id; + return value; + }, + }, + update: { + process(value, params, body) { + const id: string | number = params.id; + return [id, body]; + }, + }, +}); + +/* ---------------- existing code keeps compiling ---------------- */ +// annotated params on an endpoint that takes no args +export const noArgs = new RestEndpoint({ + path: '/users', + process(value: any, params: any) { + return value; + }, +}); +// generic path, as in a factory function +export function makeEndpoint

(path: P) { + return [ + new RestEndpoint({ + path, + process(value: any, params: any) { + return value; + }, + }), + new RestEndpoint({ + path, + process(value, params) { + return [value, params]; + }, + }), + new RestEndpoint({ path }).extend({ + process(value: any, params: any) { + return value; + }, + }), + ]; +} 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 46ecdb184028..0b0457b6323c 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -23,7 +23,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 ([#4183](https://github.com/reactive/data-client/pull/4183)) +- [process() gets typed params](/blog/2026/10/03/v0.19-batch-set#extend-process-params) in `new RestEndpoint()`, `.extend()` and `resource().extend()`, so a wrong path parameter is a TypeScript error instead of `undefined` at runtime ([#4183](https://github.com/reactive/data-client/pull/4183), [#4184](https://github.com/reactive/data-client/pull/4184)) - 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)) @@ -38,6 +38,7 @@ endpoint code about 2x faster, and fixes a round of TypeScript and Vue issues. - [useCache()](/docs/api/useCache) and [useDLE()](/docs/api/useDLE) return `undefined` for a deleted entity whose refetch failed, instead of a truthy `Symbol` that slipped past `if (!data)` checks; [remove any workarounds](/blog/2026/10/03/v0.19-batch-set#deleted-entity-undefined) ([#4150](https://github.com/reactive/data-client/pull/4150)) - Vue [useFetch()](/vue/api/useFetch) keeps its data from being garbage collected while mounted, like [useSuspense()](/vue/api/useSuspense), so a configured `gcPolicy` no longer evicts prefetched data that components read later ([#4152](https://github.com/reactive/data-client/pull/4152)) - Apps with [Redux DevTools](/docs/getting-started/debugging) open no longer stutter in development on large stores or frequent updates; each update [serializes 40-60x faster](/blog/2026/10/03/v0.19-batch-set#devtools-perf) ([#4163](https://github.com/reactive/data-client/pull/4163)) +- [resource().extend()](/rest/api/resource#extend) with an object of endpoint overrides keeps endpoints added earlier with `.extend('name', options)` in its type, so using them is no longer a TypeScript error ([#4184](https://github.com/reactive/data-client/pull/4184)) - Vue [useFetch()](/vue/api/useFetch) is typed as the read-only `Ref` it returns, so `promise.resolved` (always `undefined`) is now a TypeScript error; read `promise.value.resolved` instead ([#4114](https://github.com/reactive/data-client/pull/4114)) - Vue `createDataClient()` is deprecated, since it's internal to [DataClientPlugin](/vue/api/DataClientPlugin); install the plugin with `app.use(DataClientPlugin, options)` instead of calling it directly ([#4193](https://github.com/reactive/data-client/pull/4193)) @@ -205,8 +206,8 @@ deterministic, so they compare cleanly across machines. Union does more work tha valueLabel="v0.19" unit="K" rows={[ - { label: 'Long paths', baseline: 822, value: 346 }, - { label: 'Typical app', baseline: 20.2, value: 13.0 }, + { label: 'Long paths', baseline: 822, value: 341 }, + { label: 'Typical app', baseline: 20.2, value: 13.4 }, { label: 'React hooks', baseline: 145, value: 122 }, { label: 'Vue', baseline: 62.6, value: 47.3 }, { label: '300 fields', baseline: 17.0, value: 12.8 }, @@ -356,12 +357,13 @@ reversing `process()`. When each member declares its discriminator as a literal 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} +### Typed process() params {#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 -([#4183](https://github.com/reactive/data-client/pull/4183)): +A [process()](/rest/api/RestEndpoint#process) method passed to `new RestEndpoint()`, +[.extend()](/rest/api/RestEndpoint#extend) or [resource().extend()](/rest/api/resource#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 endpoint, including a `path` set in the same `.extend()` call +([#4183](https://github.com/reactive/data-client/pull/4183), [#4184](https://github.com/reactive/data-client/pull/4184)): diff --git a/website/src/components/Playground/editor-types/@data-client/rest.d.ts b/website/src/components/Playground/editor-types/@data-client/rest.d.ts index 029220f5d00d..db3842a0de4a 100644 --- a/website/src/components/Playground/editor-types/@data-client/rest.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/rest.d.ts @@ -1691,27 +1691,15 @@ type RestEndpointExtendOptions = RestEndpointOptions, 'schema' extends keyof O ? Extract : E['schema']> & { - /** @see https://dataclient.io/rest/api/RestEndpoint#process */ - process?(value: any, ...args: ProcessArgs>>): any; -} & PartialPick>; -/** 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. 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. +}, F extends FetchFunction> = RestEndpointOptions, 'schema' extends keyof O ? Extract : E['schema']> & PartialPick>; +/** Argument I passed to process(), from Parameters. + * Indexing a union like `[params] | []` or `[params, body] | [body]` (see ParamFetchNoBody/ParamFetchWithBody) + * merges position-wise, giving `undefined` where some call omits the argument. + * Endpoints that take no args keep `any`, so process(value, params: any) and similar still compile. */ -type ProcessArgs = 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]; -type IsUnion = T extends any ? [ - U -] extends [T] ? false : true : never; -type ArgAt0 = A extends unknown ? A['length'] extends 0 ? never : A[0] : never; -type ArgAt1 = A extends unknown ? A['length'] extends 0 | 1 ? never : A[1] : never; +type ProcessArg = [ + A['length'] +] extends [0] ? any : A[I]; type ExtendOmitKeys = KeyofRestEndpoint | keyof PartialRestGenerics | keyof RestEndpointOptions; /** Partial> as a single homomorphic mapped type */ type PartialPick = { @@ -1760,8 +1748,9 @@ type RestExtendedEndpoint) => O['process'] extends {} ? Promise> : 'content' extends keyof O ? Promise> : ReturnType, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect']>> & (keyof O extends KeyofRestEndpoint ? unknown : Omit) & (keyof E extends KeyofRestEndpoint ? unknown : Omit); -/** PartialRestGenerics without `process`. extend() constrains its options to this, so the - * `process` member of RestEndpointExtendOptions is the only contextual type for process() params. +/** PartialRestGenerics without `process`. Options passed to the constructor, extend() and + * resource().extend() are constrained by this, so RestEndpointOptions' typed `process` member is + * the only contextual type for process() params. */ interface ExtendableRestGenerics { /** @see https://dataclient.io/rest/api/RestEndpoint#path */ @@ -1792,6 +1781,10 @@ interface PartialRestGenerics extends ExtendableRestGenerics { interface RestGenerics extends PartialRestGenerics { readonly path: string; } +/** RestGenerics without `process`; see ExtendableRestGenerics */ +interface ConstructorRestGenerics extends ExtendableRestGenerics { + readonly path: string; +} type PaginationEndpoint = RestInstanceBase>, E['schema'], E['sideEffect'], Pick & { @@ -1903,6 +1896,8 @@ interface RestEndpointOptions): string; url?(...args: Parameters): string; + /** @see https://dataclient.io/rest/api/RestEndpoint#process */ + process?(value: any, params: ProcessArg, 0>, body: ProcessArg, 1>, ...rest: any[]): any; update?: EndpointUpdateFunction; } type RestEndpointConstructorOptions = RestEndpointOptions({ method, sideEffect, name, ...options }: RestEndpointConstructorOptions & Readonly & O & ContentSchemaGuard): RestEndpoint$1; + new ({ method, sideEffect, name, ...options }: RestEndpointConstructorOptions & Readonly & O & ContentSchemaGuard): RestEndpoint$1; readonly prototype: RestInstanceBase; } type MethodToSide = M extends string ? M extends 'GET' ? undefined : true : undefined; @@ -2027,7 +2022,7 @@ interface CustomResource; } type ExtendedResource> = Omit & T; -interface ResourceEndpointExtensions { +interface ResourceEndpointExtensions { readonly get?: RestEndpointOptions : OptionsToFunction>, R['get']['schema']> & Readonly & Get; readonly getList?: RestEndpointOptions : OptionsToFunction>, R['getList']['schema']> & Readonly & GetList; readonly update?: RestEndpointOptions : OptionsToFunction>, R['update']['schema']> & Readonly & Update; @@ -2051,7 +2046,7 @@ interface Extendable(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): R & { [key in ExtendKey]: RestExtendedEndpoint; }; - extend(this: R, options: ResourceEndpointExtensions): CustomResource; + extend(this: R, options: ResourceEndpointExtensions): CustomResource & Omit; extend>(this: R, extender: (baseResource: R) => T): ExtendedResource; } diff --git a/website/src/components/Playground/editor-types/globals.d.ts b/website/src/components/Playground/editor-types/globals.d.ts index ca3264975d2f..b90d390602bf 100644 --- a/website/src/components/Playground/editor-types/globals.d.ts +++ b/website/src/components/Playground/editor-types/globals.d.ts @@ -1695,27 +1695,15 @@ type RestEndpointExtendOptions = RestEndpointOptions, 'schema' extends keyof O ? Extract : E['schema']> & { - /** @see https://dataclient.io/rest/api/RestEndpoint#process */ - process?(value: any, ...args: ProcessArgs>>): any; -} & PartialPick>; -/** 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. 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. +}, F extends FetchFunction> = RestEndpointOptions, 'schema' extends keyof O ? Extract : E['schema']> & PartialPick>; +/** Argument I passed to process(), from Parameters. + * Indexing a union like `[params] | []` or `[params, body] | [body]` (see ParamFetchNoBody/ParamFetchWithBody) + * merges position-wise, giving `undefined` where some call omits the argument. + * Endpoints that take no args keep `any`, so process(value, params: any) and similar still compile. */ -type ProcessArgs = 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]; -type IsUnion = T extends any ? [ - U -] extends [T] ? false : true : never; -type ArgAt0 = A extends unknown ? A['length'] extends 0 ? never : A[0] : never; -type ArgAt1 = A extends unknown ? A['length'] extends 0 | 1 ? never : A[1] : never; +type ProcessArg = [ + A['length'] +] extends [0] ? any : A[I]; type ExtendOmitKeys = KeyofRestEndpoint | keyof PartialRestGenerics | keyof RestEndpointOptions; /** Partial> as a single homomorphic mapped type */ type PartialPick = { @@ -1764,8 +1752,9 @@ type RestExtendedEndpoint) => O['process'] extends {} ? Promise> : 'content' extends keyof O ? Promise> : ReturnType, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect']>> & (keyof O extends KeyofRestEndpoint ? unknown : Omit) & (keyof E extends KeyofRestEndpoint ? unknown : Omit); -/** PartialRestGenerics without `process`. extend() constrains its options to this, so the - * `process` member of RestEndpointExtendOptions is the only contextual type for process() params. +/** PartialRestGenerics without `process`. Options passed to the constructor, extend() and + * resource().extend() are constrained by this, so RestEndpointOptions' typed `process` member is + * the only contextual type for process() params. */ interface ExtendableRestGenerics { /** @see https://dataclient.io/rest/api/RestEndpoint#path */ @@ -1796,6 +1785,10 @@ interface PartialRestGenerics extends ExtendableRestGenerics { interface RestGenerics extends PartialRestGenerics { readonly path: string; } +/** RestGenerics without `process`; see ExtendableRestGenerics */ +interface ConstructorRestGenerics extends ExtendableRestGenerics { + readonly path: string; +} type PaginationEndpoint = RestInstanceBase>, E['schema'], E['sideEffect'], Pick & { @@ -1907,6 +1900,8 @@ interface RestEndpointOptions): string; url?(...args: Parameters): string; + /** @see https://dataclient.io/rest/api/RestEndpoint#process */ + process?(value: any, params: ProcessArg, 0>, body: ProcessArg, 1>, ...rest: any[]): any; update?: EndpointUpdateFunction; } type RestEndpointConstructorOptions = RestEndpointOptions({ method, sideEffect, name, ...options }: RestEndpointConstructorOptions & Readonly & O & ContentSchemaGuard): RestEndpoint$1; + new ({ method, sideEffect, name, ...options }: RestEndpointConstructorOptions & Readonly & O & ContentSchemaGuard): RestEndpoint$1; readonly prototype: RestInstanceBase; } type MethodToSide = M extends string ? M extends 'GET' ? undefined : true : undefined; @@ -2031,7 +2026,7 @@ interface CustomResource; } type ExtendedResource> = Omit & T; -interface ResourceEndpointExtensions { +interface ResourceEndpointExtensions { readonly get?: RestEndpointOptions : OptionsToFunction>, R['get']['schema']> & Readonly & Get; readonly getList?: RestEndpointOptions : OptionsToFunction>, R['getList']['schema']> & Readonly & GetList; readonly update?: RestEndpointOptions : OptionsToFunction>, R['update']['schema']> & Readonly & Update; @@ -2055,7 +2050,7 @@ interface Extendable(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): R & { [key in ExtendKey]: RestExtendedEndpoint; }; - extend(this: R, options: ResourceEndpointExtensions): CustomResource; + extend(this: R, options: ResourceEndpointExtensions): CustomResource & Omit; extend>(this: R, extender: (baseResource: R) => T): ExtendedResource; }