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