Skip to content

Commit e9a390c

Browse files
committed
internal: Document some design decisions
1 parent 4869723 commit e9a390c

1 file changed

Lines changed: 186 additions & 0 deletions

File tree

packages/rest/DESIGN_CHOICES.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Design Choices
2+
3+
This document records non-obvious design decisions in `@data-client/rest` and the
4+
trade-offs behind them. It is meant for contributors deciding whether to change a
5+
behavior — not for end users (those should use the [docs](https://dataclient.io/rest)).
6+
7+
## `Resource.create` does not track `getList` overrides
8+
9+
### Symptom
10+
11+
Given a resource whose `getList` is customized via `extend`:
12+
13+
```ts
14+
const MyResource = resource({ path: '/todos/:id', schema: Todo }).extend(
15+
Base => ({
16+
getList: Base.getList.extend({
17+
path: '/todos/:user/',
18+
body: {} as WeirdBody,
19+
}),
20+
}),
21+
);
22+
23+
MyResource.create({ user: 'bob' }, { title: 'get it done' } as WeirdBody);
24+
```
25+
26+
`MyResource.create`:
27+
28+
- **Runtime**: still POSTs to the original `getList` path (the shortened form of
29+
`/todos/:id`), not `/todos/:user/`.
30+
- **Types**: still requires the original `Partial<Denormalize<Todo>>` body and the
31+
original path args, ignoring `WeirdBody` and `:user`.
32+
33+
### Root cause
34+
35+
`create` is materialized eagerly at `resource()` construction time and is never
36+
re-derived when `getList` is replaced.
37+
38+
```ts
39+
// packages/rest/src/resource.ts
40+
const ret = {
41+
get,
42+
getList,
43+
// TODO(deprecated): remove this once we remove creates
44+
create: getList.push.extend({ name: getName('create') }),
45+
...
46+
};
47+
```
48+
49+
The type definition mirrors this: `create` is computed from the original
50+
`ResourceGenerics` `O`, not from `R['getList']`.
51+
52+
```ts
53+
// packages/rest/src/resourceTypes.ts
54+
/** @deprecated use Resource.getList.push instead */
55+
create: 'searchParams' extends keyof O ?
56+
MutateEndpoint<{
57+
path: ShortenPath<O['path']>;
58+
schema: schema.Collection<[O['schema']]>['push'];
59+
body: 'body' extends keyof O ? O['body']
60+
: Partial<Denormalize<O['schema']>>;
61+
searchParams: O['searchParams'];
62+
}>
63+
: ...;
64+
```
65+
66+
All three forms of `extend` (`string`, object-options, and function callback)
67+
produce result types that preserve `create` from the original `R` and only swap
68+
in the keys explicitly overridden:
69+
70+
```ts
71+
// packages/rest/src/resourceExtensionTypes.ts
72+
export type ExtendedResource<
73+
R extends ResourceInterface,
74+
T extends Record<string, EndpointInterface>,
75+
> = Omit<R, keyof T> & T;
76+
```
77+
78+
### Why we don't fix it
79+
80+
`create` is `@deprecated` in favor of `getList.push`, and the source carries a
81+
`TODO(deprecated): remove this once we remove creates`. `getList.push` already
82+
has the desired behavior for free because it lives on the `RestInstance`
83+
itself — when `getList` is extended, its `push` method's types are derived
84+
from the new instance:
85+
86+
```ts
87+
// packages/rest/src/RestEndpointTypes.ts (RestInstance)
88+
push: AddEndpoint<
89+
F,
90+
ExtractCollection<S>['push'],
91+
Omit<O, 'body' | 'method'> & {
92+
body:
93+
| OptionsToAdderBodyArgument<O, ExtractCollection<S>['push']>
94+
| OptionsToAdderBodyArgument<O, ExtractCollection<S>['push']>[]
95+
| FormData;
96+
}
97+
>;
98+
```
99+
100+
So `MyResource.getList.push({ user: 'bob' }, { title: 'get it done' } as WeirdBody)`
101+
works correctly with no library changes.
102+
103+
### What a fix would look like (if we ever decide to do it)
104+
105+
For completeness, the patch would touch four places. None of them is large; the
106+
reason it hasn't been done is value-to-cost relative to a method already slated
107+
for removal.
108+
109+
**Runtime** (`packages/rest/src/resource.ts`): re-derive `create` in each
110+
`extend` branch when `getList` was overridden and `create` was not. Example for
111+
the function-form branch:
112+
113+
```ts
114+
} else if (typeof args[0] === 'function') {
115+
const extended = args[0](this);
116+
const merged = { ...this, ...extended };
117+
if ('getList' in extended && !('create' in extended)) {
118+
merged.create = merged.getList.push.extend({ name: getName('create') });
119+
}
120+
return merged;
121+
}
122+
```
123+
124+
A `defineProperty` getter on the original `ret` is tempting but does not
125+
survive the `{ ...this }` spreads used throughout `extend`, so explicit
126+
re-binding is simpler.
127+
128+
**Types** (three locations):
129+
130+
1. `ExtendedResource<R, T>` — when `'getList' extends keyof T`, intersect in
131+
`{ create: T['getList']['push'] }` and add `'create'` to the `Omit` from `R`.
132+
2. `CustomResource<R, O, Get, GetList, ...>` — when `GetList` is non-default,
133+
re-derive `create` from the rewritten `getList`.
134+
3. `ResourceExtension<R, ExtendKey, O>` — when `ExtendKey extends 'getList'`,
135+
also rewrite `create`.
136+
137+
`Resource<O>` itself does not need to change; it is only the extend results
138+
that mis-track today.
139+
140+
A cleaner-looking attempt using polymorphic `this` (`create: this['getList']['push']`)
141+
does **not** work — TypeScript's `this` type is preserved through method
142+
signatures, but in property type positions the lookup re-resolves to the
143+
declaring interface, not the intersected result of `Omit<R, keyof T> & T`.
144+
Explicit re-derivation in each extend result type is the only working approach.
145+
146+
### Edge case: user-supplied `create`
147+
148+
Any fix must not clobber a user-provided `create`:
149+
150+
```ts
151+
resource(...).extend(Base => ({
152+
getList: Base.getList.extend({ path: '/todos/:user/' }),
153+
create: someCustomEndpoint, // do not overwrite this
154+
}));
155+
```
156+
157+
The runtime check is `'getList' in extended && !('create' in extended)`. The
158+
type-level equivalent is conditioning on `'create' extends keyof T` before
159+
re-deriving.
160+
161+
### Type-checking performance considerations
162+
163+
The proposed type changes short-circuit cleanly on the paths that don't use the
164+
scenario:
165+
166+
- Base `Resource<O>` is unchanged — every `resource(...)` call costs the same.
167+
- `.extend(...)` calls that don't touch `getList` add one short-circuiting
168+
conditional (`'getList' extends keyof T ? ... : {}`).
169+
- `.extend(...)` calls that override `getList` evaluate
170+
`RestExtendedEndpoint<...>['push']`, but anyone who reads
171+
`MyResource.getList.push` already pays this cost; TypeScript caches indexed
172+
access on a given type.
173+
174+
The most user-visible cost would be in editor hover and error rendering, since
175+
`Resource` and `CustomResource` already produce verbose tooltips. Anyone
176+
attempting this change should compare `tsc --noEmit --extendedDiagnostics`
177+
(or `--generateTrace`) before and after on a representative consumer project.
178+
Anything under 5% on `Check time` / `Instantiations` is in the noise; over 15%
179+
is a regression worth pushing back on.
180+
181+
### Recommendation
182+
183+
Use `MyResource.getList.push(...)` instead of `MyResource.create(...)`. If
184+
`create` is ever revived as a non-deprecated API, prefer removing the eager
185+
materialization and adding the four type-level re-derivations above, rather
186+
than special-casing the override scenarios.

0 commit comments

Comments
 (0)