Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .agents/skills/data-client-rest-setup/references/RestEndpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions .agents/skills/data-client-rest/references/RestEndpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions .agents/skills/data-client-rest/references/RestEndpoint.vue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions .changeset/extend-process-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@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('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' });

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`.

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`.
14 changes: 14 additions & 0 deletions docs/rest/api/RestEndpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions packages/rest/src-4.1-types/resourceExtendable.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<O extends ResourceGenerics = {
path: ResourcePath;
schema: any;
}> {
extend<R extends {
[K in ExtendKey]: RestInstanceBase;
}, ExtendKey extends Exclude<Extract<keyof R, string>, 'extend'>, ExtendOptions extends PartialRestGenerics | {}>(this: R, key: ExtendKey, options: Readonly<RestEndpointExtendOptions<ExtendOptions, R[ExtendKey], EndpointToFunction<R[ExtendKey]>> & ExtendOptions> & ExtendOptions): ResourceExtension<R, ExtendKey, ExtendOptions>;
}, ExtendKey extends Exclude<Extract<keyof R, string>, 'extend'>, ExtendOptions extends ExtendableRestGenerics | {}>(this: R, key: ExtendKey, options: Readonly<RestEndpointExtendOptions<ExtendOptions, R[ExtendKey], EndpointToFunction<R[ExtendKey]>> & ExtendOptions> & ExtendOptions): ResourceExtension<R, ExtendKey, ExtendOptions>;
extend<R extends {
get: RestInstanceBase;
}, ExtendKey extends string, ExtendOptions extends PartialRestGenerics | {}>(this: R, key: ExtendKey, options: Readonly<RestEndpointExtendOptions<ExtendOptions, R['get'], EndpointToFunction<R['get']>> & ExtendOptions> & ExtendOptions): R & {
}, 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>;
Expand Down
62 changes: 56 additions & 6 deletions packages/rest/src/RestEndpointTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ type ContentSchemaGuard<O> =
* `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`.
* @see https://dataclient.io/rest/api/RestEndpoint#extend
*/
extend<
E extends RestInstanceBase,
ExtendOptions extends PartialRestGenerics | {},
ExtendOptions extends ExtendableRestGenerics | {},
>(
this: E,
options: Readonly<
Expand Down Expand Up @@ -272,7 +272,13 @@ 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 @@ -282,6 +288,45 @@ 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.
*/
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 ExtendOmitKeys =
KeyofRestEndpoint | keyof PartialRestGenerics | keyof RestEndpointOptions;

Expand Down Expand Up @@ -430,7 +475,10 @@ export type RestExtendedEndpoint<
(keyof E extends KeyofRestEndpoint ? unknown
: Omit<E, KeyofRestEndpoint | keyof O>);

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 */
Expand All @@ -445,11 +493,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
Expand Down
5 changes: 3 additions & 2 deletions packages/rest/src/resourceExtendable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
ResourceInterface,
} from './resourceTypes.js';
import type {
ExtendableRestGenerics,
PartialRestGenerics,
RestEndpointExtendOptions,
RestExtendedEndpoint,
Expand All @@ -35,7 +36,7 @@ export interface Extendable<
},
const ExtendKey extends Exclude<Extract<keyof R, string>, 'extend'>,
// TODO: see RestEndpoint.extend TODO
ExtendOptions extends PartialRestGenerics | {},
ExtendOptions extends ExtendableRestGenerics | {},
>(
this: R,
key: ExtendKey,
Expand All @@ -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,
Expand Down
Loading
Loading