Skip to content

Commit e93e820

Browse files
authored
fix(rest): SoftPathArgs for widened path inference in RestEndpoint subclasses (#3847)
* fix(rest): Guard RestEndpoint/ConstructorOptions against O=any inference Add `unknown extends O ? any :` before the searchParams conditional in RestEndpoint<O> and RestEndpointConstructorOptions<O>. When subclassing with `O extends RestGenerics = any`, this catches O=any before it reaches PathArgs, preventing the restrictive index-signature type. Includes detailed comment explaining the partial inference limitation where TypeScript may widen path literals to `string` due to complex conditional constructor parameter types. Follows up on #3845 which fixed PathArgs<any> but missed the higher-level propagation through RestEndpointTypes. Made-with: Cursor * fix(rest): SoftPathArgs collapses widened path to unknown for subclass constructors Introduce SoftPathArgs<P> that resolves PathArgs<string> to `unknown`, preventing union overloads in ParamFetchWithBody when path widens. Infer method as POST when explicit body is provided. Add comprehensive type tests for widened-path endpoints (all callback overrides: getOptimisticResponse, key, url, process) and resource() with AuthdEndpoint subclass (extend per-endpoint, object form, function form). Update src-4.0-types legacy replacement with SoftPathArgs export. Made-with: Cursor * fix: Cover more cases * docs: Changeset from user view * fix: Method property type lacks body-based inference, causing inconsistency
1 parent 7b99cd3 commit e93e820

9 files changed

Lines changed: 404 additions & 63 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@data-client/rest': patch
3+
---
4+
5+
Fix TypeScript for `RestEndpoint` subclasses when the path is inferred as `string`
6+
7+
If you extend `RestEndpoint` with a generic such as `O extends RestGenerics = any`, TypeScript can widen a path literal to `string`. Constructor callbacks like `getOptimisticResponse`, `key`, `url`, and `process` could then get the wrong parameter types (or unusable unions), even though calling the endpoint still worked at runtime.
8+
9+
The same problem could show up when you set `searchParams: undefined` explicitly next to a `body` and a widened path. Both cases now type-check as you would expect.
10+
11+
```typescript
12+
import { Entity } from '@data-client/endpoint';
13+
import { RestEndpoint, RestGenerics } from '@data-client/rest';
14+
15+
class Item extends Entity {
16+
readonly id = '';
17+
}
18+
19+
class AppRestEndpoint<O extends RestGenerics = any> extends RestEndpoint<O> {}
20+
21+
new AppRestEndpoint({
22+
path: '/items' as string,
23+
schema: Item,
24+
body: {} as { name: string },
25+
getOptimisticResponse(_snap, body) {
26+
body.name;
27+
return body;
28+
},
29+
});
30+
31+
new AppRestEndpoint({
32+
path: '/search' as string,
33+
searchParams: undefined,
34+
schema: Item,
35+
body: {} as { q: string },
36+
getOptimisticResponse(_snap, body) {
37+
body.q;
38+
return body;
39+
},
40+
});
41+
```

.cursor/skills/changeset/SKILL.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: changeset
3-
description: Create changesets for version bump, release notes, changelog, semver patch/minor/major, breaking changes, documentation and skill updates after code changes
3+
description: Create user-focused changesets (changelog entries) for semver bumps, release notes, breaking changes, and docs; prefer impact and code examples over implementation detail
44
disable-model-invocation: true
55
---
66

@@ -27,6 +27,7 @@ Generate changesets, update documentation, draft blog entries, and update skills
2727
- Select all affected packages (direct + transitive)
2828
- Choose appropriate version bump (patch/minor/major)
2929
- For packages under 1.0, use minor for breaking changes
30+
- When writing the markdown body, follow **Changeset body quality** below (user-visible outcome, code examples when helpful, no internal implementation narrative)
3031

3132
4. **Update documentation**
3233
- Update primary docs in `docs/` for any changed public APIs
@@ -47,19 +48,26 @@ Generate changesets, update documentation, draft blog entries, and update skills
4748
- If new APIs or patterns are introduced that agents should know about, add them to the relevant skill
4849
- Skill changes don't need changesets — they are development tooling, not published packages
4950

50-
## Writing Perspective
51-
All user-facing text (changesets, blog entries, docs) should be written from the library user's point of view. Describe what was broken or what they can now do — not internal type mechanics or implementation details. Think "what does a developer using this package experience?"
51+
## Writing perspective
52+
All user-facing text (changesets, blog entries, docs) should be written from the library user's point of view. Answer: **what did the user see go wrong, and what works for them now?** Avoid internal names (conditional types, branch names, helper types like `SoftPathArgs`, file paths, PR numbers) unless the audience is maintainers reading a technical appendix — changeset bodies are for consumers reading the changelog.
5253

53-
## Changeset Format
54+
## Changeset body quality
55+
1. **Lead with impact** — One short title line, then 1–3 sentences on behavior: errors gone, typings improved, new capability, migration note.
56+
2. **User vocabulary** — Name public APIs (`RestEndpoint`, `resource()`, hook names). Do not explain how the fix was implemented.
57+
3. **When to add code** — Prefer a minimal example when the change is TypeScript-only or subtle: show the pattern that was broken and now works (subclass, `extend`, option object). Skip examples for trivial renames or obvious one-line fixes.
58+
4. **Examples** — Realistic imports and types; omit unrelated options. For fixes, you can show one “now types correctly” snippet instead of a long before/after if the before state was “TypeScript error on …”.
59+
5. **Breaking changes** — Still say what the user must do; use Before/After sections with code when the migration is non-obvious.
60+
61+
## Changeset format
5462
- **First line**: Action verb ("Add", "Fix", "Update", "Remove")
5563
- **Breaking**: Prefix with `BREAKING CHANGE:` or `BREAKING:`
56-
- **Body**: 1–3 lines describing outcome, not implementation
57-
- **New exports**: Use "New exports:" with bullet list
64+
- **Body**: User outcome first; implementation almost never belongs here
65+
- **New exports**: Use "New exports:" with a bullet list
5866

59-
## Code Examples in Changesets
60-
- Fixes: `// Before: ... ❌` `// After: ... ✓`
61-
- Breaking changes: Use `#### Before` and `#### After` headers
62-
- Multiple use cases: Separate with brief labels
67+
## Code examples in changesets
68+
- **Fixes**: Optional `// Before:` / `// After:` comments in one block, or two small blocks — keep them copy-paste plausible
69+
- **Breaking changes**: Use `#### Before` and `#### After` headers with complete snippets
70+
- **Multiple scenarios**: Short intro line per scenario, or separate fenced blocks with a one-line label above each
6371

6472
## Markdown Formatting
6573
Follow `@.cursor/rules/markdown-formatting.mdc` for all markdown content.

packages/rest/src-4.0-types/pathTypes.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ export declare type PathArgs<S extends string> = PathKeys<S> extends never
66
? unknown
77
: KeysToArgs<PathKeys<S>>;
88

9+
/** Like PathArgs but widened `path: string` collapses to `unknown` */
10+
export declare type SoftPathArgs<P extends string> =
11+
unknown extends P ? any
12+
: string extends P ? unknown
13+
: PathArgs<P>;
14+
915
export declare type KeysToArgs<Key extends string> = {
1016
[K in Key]?: string | number;
1117
};

packages/rest/src/RestEndpointTypes.ts

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
OptionsToBodyArgument,
1212
OptionsToFunction,
1313
} from './OptionsToFunction.js';
14-
import { PathArgs } from './pathTypes.js';
14+
import { PathArgs, SoftPathArgs } from './pathTypes.js';
1515
import { EndpointUpdateFunction } from './RestEndpointTypeHelp.js';
1616

1717
export interface RestInstanceBase<
@@ -520,6 +520,18 @@ type OptionsBodyDefault<O extends RestGenerics> =
520520
: O['method'] extends 'POST' | 'PUT' | 'PATCH' ? O & { body: any }
521521
: O & { body: undefined };
522522

523+
/** When `method` is omitted from `O`, infer it (must stay aligned with `OptionsToBodyArgument`). */
524+
type InferRestMethodWhenOmitted<O extends RestGenerics> =
525+
O extends { sideEffect: true } ? 'POST'
526+
: 'body' extends keyof O ?
527+
[O['body']] extends [undefined] ?
528+
'GET'
529+
: 'POST'
530+
: 'GET';
531+
532+
type MethodArgForBodyInference<O extends RestGenerics> =
533+
'method' extends keyof O ? O['method'] : InferRestMethodWhenOmitted<O>;
534+
523535
type OptionsToAdderBodyArgument<O extends { body?: any }> =
524536
'body' extends keyof O ? O['body'] : any;
525537

@@ -558,20 +570,21 @@ export interface RestEndpointOptions<
558570
update?: EndpointUpdateFunction<F, S>;
559571
}
560572

573+
// When subclassing RestEndpoint with `O extends RestGenerics = any`, O defaults
574+
// to `any`. The `unknown extends O ? any` guard catches O=any before it reaches
575+
// PathArgs (see #3782). SoftPathArgs collapses PathArgs<string> to `unknown`
576+
// when a concrete body is present, preventing union overloads that break
577+
// getOptimisticResponse callbacks. Method inference treats explicit body as POST.
561578
export type RestEndpointConstructorOptions<O extends RestGenerics = any> =
562579
RestEndpointOptions<
563580
RestFetch<
564-
'searchParams' extends keyof O ?
581+
unknown extends O ? any
582+
: 'searchParams' extends keyof O ?
565583
[O['searchParams']] extends [undefined] ?
566-
PathArgs<O['path']>
567-
: O['searchParams'] & PathArgs<O['path']>
568-
: PathArgs<O['path']>,
569-
OptionsToBodyArgument<
570-
O,
571-
'method' extends keyof O ? O['method']
572-
: O extends { sideEffect: true } ? 'POST'
573-
: 'GET'
574-
>,
584+
SoftPathArgs<O['path']>
585+
: O['searchParams'] & SoftPathArgs<O['path']>
586+
: SoftPathArgs<O['path']>,
587+
OptionsToBodyArgument<O, MethodArgForBodyInference<O>>,
575588
O['process'] extends {} ? ReturnType<O['process']>
576589
: any /*Denormalize<O['schema']>*/
577590
>,
@@ -586,26 +599,22 @@ export interface RestEndpoint<
586599
O extends RestGenerics = any,
587600
> extends RestInstance<
588601
RestFetch<
589-
'searchParams' extends keyof O ?
602+
unknown extends O ? any
603+
: 'searchParams' extends keyof O ?
590604
[O['searchParams']] extends [undefined] ?
591605
PathArgs<O['path']>
592606
: O['searchParams'] & PathArgs<O['path']>
593607
: PathArgs<O['path']>,
594-
OptionsToBodyArgument<
595-
O,
596-
'method' extends keyof O ? O['method']
597-
: O extends { sideEffect: true } ? 'POST'
598-
: 'GET'
599-
>,
608+
OptionsToBodyArgument<O, MethodArgForBodyInference<O>>,
600609
O['process'] extends {} ? ReturnType<O['process']>
601610
: any /*Denormalize<O['schema']>*/
602611
>,
603612
'schema' extends keyof O ? O['schema'] : undefined,
604613
'sideEffect' extends keyof O ? Extract<O['sideEffect'], boolean | undefined>
605-
: MethodToSide<O['method']>,
614+
: MethodToSide<MethodArgForBodyInference<O>>,
606615
'method' extends keyof O ? O
607616
: O & {
608-
method: O extends { sideEffect: true } ? 'POST' : 'GET';
617+
method: InferRestMethodWhenOmitted<O>;
609618
}
610619
> {}
611620

packages/rest/src/pathTypes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ export type PathArgs<S extends string> =
1818
unknown
1919
: KeysToArgs<PathKeys<S>>;
2020

21+
/** Like {@link PathArgs} but widened `path: string` collapses to `unknown`,
22+
* preventing `(params, body) | (body)` union overloads in ParamFetchWithBody. */
23+
export type SoftPathArgs<P extends string> =
24+
unknown extends P ? any
25+
: string extends P ? unknown
26+
: PathArgs<P>;
27+
2128
/** Computes the union of keys for a path string */
2229
export type PathKeys<S extends string> =
2330
string extends S ? string

0 commit comments

Comments
 (0)