Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/data-client-rest/references/RestEndpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
4 changes: 2 additions & 2 deletions .changeset/extend-process-params.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
17 changes: 17 additions & 0 deletions .changeset/resource-extend-keeps-members.md
Original file line number Diff line number Diff line change
@@ -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);
```
4 changes: 2 additions & 2 deletions docs/rest/api/RestEndpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
4 changes: 2 additions & 2 deletions packages/rest/src-4.1-types/resourceExtendable.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<O extends ResourceGenerics = {
path: ResourcePath;
schema: any;
Expand All @@ -15,7 +15,7 @@ export interface Extendable<O extends ResourceGenerics = {
}, ExtendKey extends string, ExtendOptions extends ExtendableRestGenerics | {}>(this: R, key: ExtendKey, options: Readonly<RestEndpointExtendOptions<ExtendOptions, R['get'], EndpointToFunction<R['get']>> & ExtendOptions> & ExtendOptions): R & {
[key in ExtendKey]: RestExtendedEndpoint<ExtendOptions, R['get']>;
};
extend<R extends ResourceInterface, Get extends PartialRestGenerics = {}, GetList extends PartialRestGenerics = {}, Update extends PartialRestGenerics = {}, PartialUpdate extends PartialRestGenerics = {}, Delete extends PartialRestGenerics = {}>(this: R, options: ResourceEndpointExtensions<R, Get, GetList, Update, PartialUpdate, Delete>): CustomResource<R, O, Get, GetList, Update, PartialUpdate, Delete>;
extend<R extends ResourceInterface, Get extends ExtendableRestGenerics = {}, GetList extends ExtendableRestGenerics = {}, Update extends ExtendableRestGenerics = {}, PartialUpdate extends ExtendableRestGenerics = {}, Delete extends ExtendableRestGenerics = {}>(this: R, options: ResourceEndpointExtensions<R, Get, GetList, Update, PartialUpdate, Delete>): CustomResource<R, O, Get, GetList, Update, PartialUpdate, Delete> & Omit<R, keyof ResourceInterface | 'extend'>;
extend<R extends ResourceInterface, T extends Record<string, EndpointInterface>>(this: R, extender: (baseResource: R) => T): ExtendedResource<R, T>;
}
//# sourceMappingURL=resourceExtendable.d.ts.map
71 changes: 24 additions & 47 deletions packages/rest/src/RestEndpointTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,13 +272,7 @@ export type RestEndpointExtendOptions<
OptionsToFunction<O, E, F>,
'schema' extends keyof O ? Extract<O['schema'], Schema | undefined>
: E['schema']
> & {
/** @see https://dataclient.io/rest/api/RestEndpoint#process */
process?(
value: any,
...args: ProcessArgs<Parameters<OptionsToFunction<O, E, F>>>
): any;
} &
> &
// Same as Partial<Omit<E, ExtendOmitKeys>>, 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
Expand All @@ -288,44 +282,13 @@ export type RestEndpointExtendOptions<
keyof E extends ExtendOmitKeys ? never : Exclude<keyof E, ExtendOmitKeys>
>;

/** Parameters<F> 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<F>`, the stricter signature for callers.
/** Argument I passed to process(), from Parameters<F>.
* 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<A extends readonly any[]> =
// 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<A> extends false ? A
: [] extends A ?
[ArgAt1<A>] extends [never] ?
[params?: ArgAt0<A>]
: [params?: ArgAt0<A>, body?: ArgAt1<A>]
: [params: ArgAt0<A>, body?: ArgAt1<A>];
type IsUnion<T, U = T> =
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 readonly any[]> =
A extends unknown ?
A['length'] extends 0 ?
never
: A[0]
: never;
type ArgAt1<A extends readonly any[]> =
A extends unknown ?
A['length'] extends 0 | 1 ?
never
: A[1]
: never;
type ProcessArg<A extends readonly any[], I extends 0 | 1> =
[A['length']] extends [0] ? any : A[I];

type ExtendOmitKeys =
KeyofRestEndpoint | keyof PartialRestGenerics | keyof RestEndpointOptions;
Expand Down Expand Up @@ -475,8 +438,9 @@ export type RestExtendedEndpoint<
(keyof E extends KeyofRestEndpoint ? unknown
: Omit<E, KeyofRestEndpoint | keyof O>);

/** 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 */
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -695,6 +663,15 @@ export interface RestEndpointOptions<
fetch?: F;
key?(...args: Parameters<F>): string;
url?(...args: Parameters<F>): 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<Parameters<F>, 0>,
body: ProcessArg<Parameters<F>, 1>,
...rest: any[]
): any;
update?: EndpointUpdateFunction<F, S>;
}

Expand Down Expand Up @@ -754,7 +731,7 @@ export interface RestEndpointConstructor {
*
* @see https://dataclient.io/rest/api/RestEndpoint
*/
new <O extends RestGenerics = any>({
new <O extends ConstructorRestGenerics = any>({
method,
sideEffect,
name,
Expand Down
15 changes: 8 additions & 7 deletions packages/rest/src/resourceExtendable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import type {
} from './resourceTypes.js';
import type {
ExtendableRestGenerics,
PartialRestGenerics,
RestEndpointExtendOptions,
RestExtendedEndpoint,
RestInstanceBase,
Expand Down Expand Up @@ -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<
Expand All @@ -87,7 +86,9 @@ export interface Extendable<
PartialUpdate,
Delete
>,
): CustomResource<R, O, Get, GetList, Update, PartialUpdate, Delete>;
): CustomResource<R, O, Get, GetList, Update, PartialUpdate, Delete> &
// keeps members like those added by extend('key', options)
Omit<R, keyof ResourceInterface | 'extend'>;
extend<
R extends ResourceInterface,
T extends Record<string, EndpointInterface>,
Expand Down
11 changes: 6 additions & 5 deletions packages/rest/src/resourceExtensionTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<R['get']>
Expand Down
45 changes: 45 additions & 0 deletions packages/rest/typescript-tests/extendOptions.test.ts
Original file line number Diff line number Diff line change
@@ -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 }),
];
Loading
Loading