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
22 changes: 22 additions & 0 deletions .changeset/controller-set-value-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@data-client/core': patch
'@data-client/react': patch
'@data-client/vue': patch
---

Fix `controller.set()` accepting any value

Values are now typed by the schema: Entities take their fields, while [Collection](https://dataclient.io/rest/api/Collection)
and [All](https://dataclient.io/rest/api/All) take a list of rows. [Query](https://dataclient.io/rest/api/Query) takes
the input of the schema it wraps, not what its `process()` returns. Updater functions must return the same.

```ts
// Before: these all typechecked, then failed or wrote nothing at runtime
ctrl.set(new schema.All(Todo), 42);
ctrl.set(TodoResource.getList.schema, 'anything');
ctrl.set(Todo, { id: '5' }, { id: '5', completed: 'yes' });

// After: TypeScript errors on the above; these typecheck
ctrl.set(TodoResource.getList.schema, [{ id: '5', completed: true }]);
ctrl.set(new schema.All(Todo), [{ id: '5', completed: true }]);
```
17 changes: 17 additions & 0 deletions docs/core/api/Controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,23 @@ ctrl.set(
);
```

The value is typed by the schema: an [Entity](/rest/api/Entity) takes its fields (numbers and strings may be either),
while a [Collection](/rest/api/Collection) or [All](/rest/api/All) takes a list of rows. A [Query](/rest/api/Query)
takes the input of the schema it wraps, since `set()` normalizes that schema rather than reversing `process()`.

```ts
ctrl.set(TodoResource.getList.schema, [{ id: '5', completed: true }]);
```

:::note Type checking limits

To keep type checking fast for large [Unions](/rest/api/Union), a Union row is checked against the
combined fields of all its members rather than against one member. Each field's type is still checked,
but a row that mixes fields from different members (like `{ type: 'first', secondField: 1 }`) is not
an error. Make sure the fields you set belong to the member the row's discriminator selects.

:::

Functions can be used in the value when derived data is used. This [prevents race conditions](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state).

```ts
Expand Down
22 changes: 14 additions & 8 deletions packages/core/src/controller/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ import {
createSetResponse,
} from './actions/index.js';
import ensurePojo from './ensurePojo.js';
import type { SetManySchema, SetManyValue } from './setManyTypes.js';
import type {
SkipInfer,
SetManySchema,
SetManyValue,
SetValue,
} from './setManyTypes.js';
import type { EndpointUpdateFunction } from './types.js';
import { ReduxMiddlewareAPI } from '../manager/applyManager.js';
import type { GCInterface } from '../state/GCPolicy.js';
Expand Down Expand Up @@ -226,12 +231,13 @@ export default class Controller<
*/
set<S extends Queryable>(
schema: S,
...rest: readonly [...SchemaArgs<S>, (previousValue: Denormalize<S>) => {}]
): Promise<void>;

set<S extends Queryable>(
schema: S,
...rest: readonly [...SchemaArgs<S>, {}]
...rest: readonly [
...SchemaArgs<S>,
SkipInfer<
SetValue<S> | ((previousValue: Denormalize<S>) => SetValue<S>),
S
>,
]
): Promise<void>;

/**
Expand All @@ -240,7 +246,7 @@ export default class Controller<
*/
set<S extends SetManySchema>(
schema: S,
value: SetManyValue<S>,
value: SkipInfer<SetManyValue<S>, S>,
): Promise<void>;

set<S extends Queryable>(
Expand Down
90 changes: 72 additions & 18 deletions packages/core/src/controller/setManyTypes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** Types for batch `Controller.set([Entity], rows)` */
/** Value types for `Controller.set()`, including batch `set([Entity], rows)` */
import type { Denormalize, EntityInterface } from '@data-client/normalizr';

/** What one row normalizes to: a reference to one stored entity */
Expand Down Expand Up @@ -37,34 +37,88 @@ type FunctionKeys<U> = {
[K in keyof U]: U[K] extends (...args: any) => any ? K : never;
}[keyof U];

/** Raw input for one field: numbers and strings coerce; objects are pre-normalize */
/** Raw input for one field: numbers and strings coerce (literals stay exact);
* objects are pre-normalize */
type SetField<T> =
T extends number ? T | string
: T extends string ? T | number
T extends number ?
number extends T ?
T | string
: T
: T extends string ?
string extends T ?
T | number
: T
: T extends object ? unknown
: T;

/** Fields of one row; like EntityFields, but distributive and without key remapping (TS 4.0) */
/** Non-function keys of any member of U */
type FieldKeys<U> =
U extends unknown ? Exclude<keyof U, FunctionKeys<U>> : never;

/** Input for field K, from each member of U that has it */
type MemberField<U, K> =
U extends unknown ?
K extends keyof U ?
SetField<U[K]>
: never
: never;

/** Fields of one row (or a coerced primitive); like EntityFields, but without
* key remapping (TS 4.0). A Union's members merge into one object type: checking
* a row against it costs one comparison instead of one per member. */
type SetRow<U> =
// EntityMixin and other untyped entities
0 extends 1 & U ? { readonly [k: string]: any }
: U extends unknown ?
{ readonly [K in Exclude<keyof U, FunctionKeys<U>>]?: SetField<U[K]> }
: never;
: [U] extends [object] ? { readonly [K in FieldKeys<U>]?: MemberField<U, K> }
: SetField<U>;

/** Polymorphic rows may carry a discriminator that is not an Entity field */
type SetRowOf<Sch, U> =
Sch extends EntityInterface ? SetRow<U>
: SetRow<U> & { readonly [k: string]: unknown };
/** Keeps S inferred from the schema alone: inferring it from the value too would
* walk the value's type against every conditional in SetValue (TS 5.4 has NoInfer) */
export type SkipInfer<T, S> = [T][S extends unknown ? 0 : never];

export type SetManyValue<S> =
S extends readonly (infer E)[] ?
true extends IsUnion<E> ?
readonly { 'Use a Union schema for several Entity types': never }[]
: readonly SetRowOf<E, Denormalize<E>>[]
: S extends { readonly schema: infer Sch } ?
Denormalize<S> extends readonly (infer U)[] ? readonly SetRowOf<Sch, U>[]
: Denormalize<S> extends { readonly [k: string]: infer U } ?
{ readonly [k: string]: SetRowOf<Sch, U> }
: never
: readonly SetItem<Denormalize<E>>[]
: SetValue<S>;

/** Raw input `set()` normalizes for a Queryable */
export type SetValue<S> =
InputSchema<InputSchema<InputSchema<S>>> extends infer N ?
N extends EntityInterface ?
SetRow<Denormalize<N>>
: SetInput<Denormalize<N>>
: never;

/** Query normalizes with its inner schema; its process() output is not input
*
* Applied three times in SetValue to unwrap nested Queries (TS 4.0 has no recursive aliases)
*/
type InputSchema<S> =
S extends (
{
readonly schema: infer Sch;
process(...args: any): any;
// excludes Entity, whose static schema and process() match the members above
pk?: never;
}
) ?
Sch
: S;
Comment thread
cursor[bot] marked this conversation as resolved.

/** Raw input for a denormalized value, like a Collection's list or a Union's row */
type SetInput<T> =
0 extends 1 & T ? any
: // not distributive, so a Union's members stay together for SetItem
[T] extends [readonly (infer U)[]] ? readonly SetItem<U>[]
: string extends keyof T ? { readonly [k: string]: SetItem<T[keyof T]> }
: SetItem<T>;

/**
* One member of a list or keyed object. Polymorphic rows may carry a
* discriminator that is not an Entity field.
*/
type SetItem<U> =
true extends IsUnion<U> ? SetRow<U> & { readonly [k: string]: unknown }
: SetRow<U>;
63 changes: 63 additions & 0 deletions packages/react/src/hooks/__tests__/useController/set.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { schema } from '@data-client/rest';
import {
ArticleFromMixin,
CoolerArticle,
CoolerArticleResource,
FirstUnion,
SecondUnion,
UnionResource,
Expand Down Expand Up @@ -252,20 +253,82 @@ describe('set', () => {
]);
// @ts-expect-error id is a number
controller.set([new schema.Invalidate(CoolerArticle)], [{ id: false }]);
controller.set(byKind, { id: '1' }, { id: '1', kind: 'first' });
// @ts-expect-error body is a string
controller.set(byKind, { id: '1' }, { id: '1', body: false });
// EntityMixin rows
controller.set([ArticleFromMixin], [{ id: 5, title: 'mixin' }]);
// @ts-expect-error title is a string
controller.set(new schema.Values(CoolerArticle), { a: { title: false } });
};
});

it('should type values by the schema', async () => {
const { controller } = renderDataClient(() => null);
const list = CoolerArticleResource.getList.schema;
const all = new schema.All(CoolerArticle);
const titles = new schema.Query(all, articles =>
articles.map(article => article.title),
);
const titleCount = new schema.Query(titles, list => list.length);
let promise: any;
act(() => {
promise = controller.set(list, [{ id: 5, title: 'listed' }]);
});
await act(() => promise);
act(() => {
promise = controller.set(titles, [{ id: 6, title: 'queried' }]);
});
await act(() => promise);
act(() => {
promise = controller.set(titleCount, [{ id: 8, title: 'nested' }]);
});
await act(() => promise);
const state = controller.getState();
expect(controller.get(list, state)?.map(({ id }) => id)).toEqual([5]);
expect(controller.get(titles, state)?.sort()).toEqual([
'listed',
'nested',
'queried',
]);
expect(controller.get(titleCount, state)).toBe(3);

// type tests
() => {
controller.set(all, [{ id: '5', title: 'coerced' }]);
controller.set(list, articles => [
...articles.map(({ id }) => ({ id })),
{ id: 7 },
]);
// @ts-expect-error All takes a list of rows
controller.set(all, 42);
// @ts-expect-error title is a string
controller.set(all, [{ id: 5, title: false }]);
// @ts-expect-error Collections take a list of rows
controller.set(list, 'anything');
// @ts-expect-error unknown field
controller.set(list, [{ id: 5, bogus: 1 }]);
// @ts-expect-error updaters must return rows
controller.set(list, () => 42);
// @ts-expect-error Queries take their schema's input, not process() output
controller.set(titles, ['listed']);
// @ts-expect-error nested Queries take the innermost schema's input
controller.set(titleCount, 3);
// @ts-expect-error title is a string
controller.set(CoolerArticle, { id: 5 }, { id: 5, title: false });
// @ts-expect-error updaters must return the Entity's fields
controller.set(CoolerArticle, { id: 5 }, () => ({ title: false }));
};
});

it('should update store with error', async () => {
const { result, controller } = renderDataClient(() => {
return useQuery(CoolerArticle, { id: payload.id });
});
expect(result.current).toBeUndefined();
let promise: any;
act(() => {
// @ts-expect-error testing runtime error
promise = controller.set(CoolerArticle, { id: 5 }, 5);
});
expect(result.current).toBeUndefined();
Expand Down
66 changes: 66 additions & 0 deletions website/blog/2026-10-03-v0.19-batch-set.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ draft: true

import DiffEditor from '@site/src/components/DiffEditor';
import StackBlitz from '@site/src/components/StackBlitz';
import TypeScriptEditor from '@site/src/components/TypeScriptEditor';

**New APIs:**

Expand All @@ -17,6 +18,7 @@ import StackBlitz from '@site/src/components/StackBlitz';

- Fix TypeScript 7 module resolution for package `exports`; imports now resolve to declaration files ([#4019](https://github.com/reactive/data-client/pull/4019))
- [renderDataHook()](/docs/api/renderDataHook) runs provider mount effects when the first render suspends ([#4099](https://github.com/reactive/data-client/pull/4099))
- [Controller.set() values are typed by the schema](/blog/2026/10/03/v0.19-batch-set#typed-set), so `ctrl.set(new schema.All(Todo), 42)` is a TypeScript error ([#4133](https://github.com/reactive/data-client/pull/4133))
- Vue [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) keep the previous data while new arguments load, instead of returning `undefined` ([#4131](https://github.com/reactive/data-client/pull/4131))
- Vue [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) send fetch errors after arguments change to `onErrorCaptured()` instead of an unhandled promise rejection ([#4135](https://github.com/reactive/data-client/pull/4135))
- Vue [useSuspense()](/vue/api/useSuspense), [useDLE()](/vue/api/useDLE) and [useFetch()](/vue/api/useFetch) no longer refetch stale data on every store update, so a `controller.set()` is not overwritten ([#4134](https://github.com/reactive/data-client/pull/4134))
Expand Down Expand Up @@ -110,6 +112,70 @@ is the Array schema, so match `action.schema[0]` for `[Ticker]` rather than the

## Other improvements

### Typed set() values {#typed-set}

[Controller.set()](/docs/api/Controller#set) previously accepted any value for a schema, so a typo or a wrong
field type only surfaced as bad data at runtime. Values are now typed by the schema: an
[Entity](/rest/api/Entity) takes its fields, while a [Collection](/rest/api/Collection), [All](/rest/api/All) or
Array takes a list of rows. Every field is optional, since `set()` merges into what is already stored, and
numbers and strings are interchangeable just like in API responses.
[#4133](https://github.com/reactive/data-client/pull/4133)

Hover the red underlines to see each error.

<TypeScriptEditor>

```ts title="Todo" collapsed
import { Entity, resource } from '@data-client/rest';

export class Todo extends Entity {
id = 0;
userId = 0;
title = '';
completed = false;

static key = 'Todo';
}

export const TodoResource = resource({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
schema: Todo,
});
```

```ts title="updateTodos"
import type { Controller } from '@data-client/react';
import { schema } from '@data-client/rest';
import { Todo, TodoResource } from './Todo';

export function updateTodos(ctrl: Controller) {
// ✅ rows are partial Todos; ids may be strings or numbers
ctrl.set(TodoResource.getList.schema, [{ id: '5', completed: true }]);
ctrl.set(Todo, { id: 5 }, todo => ({ completed: !todo.completed }));
ctrl.set([Todo], [{ id: 1, title: 'first' }, { id: 2 }]);

// ❌ All takes a list of rows
ctrl.set(new schema.All(Todo), 42);
// ❌ completed is a boolean
ctrl.set(Todo, { id: 5 }, { id: 5, completed: 'yes' });
// ❌ Todo has no done field
ctrl.set(TodoResource.getList.schema, [{ id: 5, done: true }]);
// ❌ updaters must return Todo fields
ctrl.set(Todo, { id: 5 }, todo => ({ title: todo.completed }));
}
```

</TypeScriptEditor>

A [Query](/rest/api/Query) takes the input of the schema it wraps, since `set()` normalizes that schema rather than
reversing `process()`. To keep type checking fast for large [Unions](/rest/api/Union), a Union row is checked
against the combined fields of all its members, so a row mixing fields from different members is not an error; see
[type checking limits](/docs/api/Controller#set).

If code that previously compiled now fails here, it was writing data its schema doesn't describe. Fix the value,
or widen the Entity's field types if the data really can take that shape.

### Vue getter arguments {#vue-getter-args}

Vue composables like [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) were typed to accept a
Expand Down
Loading
Loading