Skip to content

refactor(signals): preserve the supplied type in Store<T> - #3261

Open
GabbeV wants to merge 4 commits into
solidjs:nextfrom
GabbeV:proposal/remove-store-readonly
Open

refactor(signals): preserve the supplied type in Store<T>#3261
GabbeV wants to merge 4 commits into
solidjs:nextfrom
GabbeV:proposal/remove-store-readonly

Conversation

@GabbeV

@GabbeV GabbeV commented Sep 3, 2026

Copy link
Copy Markdown

Depends on #3260. This branch is currently stacked on that PR; the overload changes should be reviewed and merged independently.

Summary

Change Store<T> from Readonly<T> to T:

export type Store<T> = T;

Store<T> remains useful as documentation in public signatures, but no longer transforms the user's value type.

This is a type-only change. Stores are still updated through their setters, and writes made directly through a store proxy are still ignored at runtime.

Why change it?

Readonly<T> provides a shallow check against some direct mutations, but it also affects inference and the types shown by tooling. In some valid store compositions it discards information that TypeScript cannot recover.

The underlying restriction is on an operation—stores must be updated through their setters—but Readonly<T> models it by changing the value type. That mismatch is the source of both the incomplete enforcement and the composition problems below.

Valid store composition loses information

The problem also exists for ordinary object types when the user intentionally mixes mutable and readonly properties:

type MutableModel = {
  id: string;
  name: string;
};

type PartlyReadonlyModel = {
  readonly id: string;
  name: string;
};

Store<MutableModel>          // { readonly id: string; readonly name: string }
Store<PartlyReadonlyModel>   // { readonly id: string; readonly name: string }

The resulting store types are identical. Their setter drafts should not be: both properties of MutableModel are writable, while PartlyReadonlyModel["id"] should remain readonly. Removing readonly from the visible store type cannot recover that distinction; it makes every property writable and loses something the user's original type could express.

Arrays and tuples show the same collapse particularly clearly because TypeScript applies readonly mapped modifiers to their containers:

Store<Todo[]>          // readonly Todo[]
Store<readonly Todo[]> // readonly Todo[]

Store<[string, number]>          // readonly [string, number]
Store<readonly [string, number]> // readonly [string, number]

After these transformations, an API receiving only the visible store type cannot determine which readonly modifiers came from the user and which were added by Store<T>.

This affects chained stores:

const [source] = createStore([] as Todo[]);
const [copy, setCopy] = createStore(source);

setCopy(draft => {
  draft.push(todo); // rejected: the original Todo[] has become readonly Todo[]
});

It also accumulates when one store is stored inside another:

type List = {
  readonly id: string;
  items: Todo[];
};

const [list] = createStore<List>({ id: "todos", items: [] });
const [app, setApp] = createStore({ list });

setApp(draft => {
  draft.list.items = []; // rejected
});

The outer draft is inferred as { list: Readonly<List> }. Recovering the intended type currently requires repeating it explicitly:

const [app, setApp] = createStore<{ list: List }>({ list });

With Store<T> = T, both examples infer the original types without explicit type arguments, while the user-authored readonly List["id"] remains readonly.

The transformation replaces the user's type in tooling

The transformation affects diagnostics, declaration output, and hovers. TypeScript may display Readonly<Model>, nested mapped types, or expanded readonly object shapes instead of the application's declared type. For example, declaration emit for a nested store produces the equivalent of:

declare const model: Readonly<Model>;
declare const app: Readonly<{
  model: Readonly<Model>;
}>;

Preserving T lets TypeScript retain the user's named types wherever it normally can.

The existing static guard is weaker than it appears

Even independently of stores, TypeScript treats readonly object properties permissively. A readonly object is assignable to the corresponding mutable object type without a cast:

interface Model {
  name: string;
}

declare const readonlyModel: Readonly<Model>;
const mutableModel: Model = readonlyModel; // accepted by TypeScript

Readonly<T> is also shallow, so Readonly<{ items: Item[] }> still exposes items.push(...). A Store<T> can therefore lose its added readonly through ordinary assignment, while nested mutable values never receive it in the first place.

This does not make Readonly<T> useless, but it means the current type is a partial guard rather than a dependable representation of the setter-only runtime contract. The proposal trades that limited guard for preserving the user's type exactly.

Why not recover a mutable type at setter boundaries?

Several alternatives were tested against objects, arrays, tuples, unions, classes, intersections, Omit, and merge helpers.

Mutable<Store<T>>

A shallow mutable mapping removes readonly at only one level, so it does not fix a store nested inside another store. It also removes readonly properties intentionally declared by the user at the level it does reach.

Recursive DeepMutable

A recursive mapping reaches nested stores, but cannot distinguish Solid-added readonly from user-authored readonly. It consequently:

  • turns intentionally readonly arrays and tuples mutable;
  • removes readonly properties from individual union branches; and
  • maps class instances to their public structural shape, losing the identity of classes with private fields.

Some of these edge cases can be mitigated. A production-quality deep utility could special-case functions, arrays, tuples, built-ins, and selected atomic or class-like types instead of applying one mapped type indiscriminately. That would reduce the structural damage, at the cost of a considerably more complex public type with more edge cases and compiler work.

Special-casing cannot solve the central ambiguity by itself: once a property is readonly, the utility still cannot tell whether that modifier came from Store<T> or from the user's original T. Preserving that distinction requires carrying additional provenance, with the composition tradeoffs described below.

S extends Store<infer T> ? T : never

Reverse mapped-type inference works surprisingly well for many structural cases. The experiments for this PR recovered useful results for plain objects, object unions, public class shapes, intersections, Omit, and merge helpers.

It cannot recover mutable arrays or tuples because the original mutable and readonly forms have already become the same type. Both array inputs infer readonly T[]; both tuple inputs infer the readonly tuple.

Carrying the original type in a brand

A phantom provenance field can recover the original array type directly, but it creates ambiguity once generic code transforms the visible value:

type Extended = Store<Person> & { active: boolean };
type WithoutAge = Omit<Store<Person>, "age">;
type Renamed = Merge<Store<Person>, { name: number }>;

In the tested branded formulation, StoreValue<Extended> recovered the stale Person and discarded active, while matching WithoutAge or Renamed against Store<infer T> produced never. Extracting only the provenance field instead retained the stale original Person for those transformations.

The type system cannot know whether a helper intended to preserve store provenance or transform the value's shape. Store-aware built-in helpers would not solve this for third-party code.

Prior art

Comparable APIs expose an object that users are expected to update through a separate operation without making that object's properties readonly in its return type:

  • React's useState<T>() returns T, not Readonly<T>; updates go through the returned dispatch function.
  • Angular's asReadonly() returns a Signal<T> that reads as T, not Readonly<T>; updates remain on the writable signal.
  • Svelte's readonly() returns Readable<T>, whose subscribers receive T, not Readonly<T>; updates remain on the writable store.
  • Vue makes object properties readonly only through dedicated helpers: readonly() returns a deeply readonly type, while shallowReadonly() returns Readonly<T>.
  • Solid 2's createSignal<T>() returns an accessor that reads as T, not Readonly<T>; reactive updates go through the returned setter.
  • Solid 1's createStore<T>() returns Store<T> = T, not Readonly<T>; updates go through setStore.

Proposed contract and tradeoff

Store<T> = T preserves the type supplied by the user, including readonly they chose themselves:

type State = {
  readonly id: number;
  name: string;
};

const [state, setState] = createStore<State>({ id: 1, name: "Ada" });

setState(draft => {
  draft.name = "Grace"; // accepted
  draft.id = 2; // still rejected
});

The tradeoff is that TypeScript will no longer reject direct writes to otherwise mutable root properties or root-array methods. Those writes still have no effect at runtime. This gives up the partial static guard described above in exchange for preserving inference, named types, and intentional readonly throughout the API.

Possible enforcement without changing the value type

Direct mutation does not have to continue failing silently. A follow-up could make the existing proxy traps throw when code attempts to assign, delete, or define a property outside a setter:

const [state] = createStore({ name: "Ada" });

state.name = "Grace";
// Runtime error: stores must be updated through their setter

This would enforce the actual runtime boundary consistently for root properties, root arrays, and nested values that are store proxies. It would also report the misuse at the operation that had no effect.

The proxy already enters set, deleteProperty, and defineProperty traps and checks whether the write is authorized by a draft or projection override. Replacing the existing ignored-write branch with an error would add no work to correct reads or setter writes. Correct code never takes that branch, so the practical cost should be limited to the small bundle-size increase for the error path.

Solid could additionally provide or recommend a lint rule for direct store writes and mutating method calls outside setters. Such a rule could not perfectly follow every alias, generic helper, or control-flow path, but neither does the current shallow readonly type. A rule could target the unsupported operation directly and cover nested stores and mutating array methods that Readonly<T> currently accepts.

Runtime enforcement could therefore provide the authoritative guarantee, with linting offering best-effort editor feedback without transforming the user's value type.

Scope

  • Preserve the supplied type in Store<T>.
  • Cover chained and nested-store inference and user-authored readonly.
  • Keep runtime behavior unchanged and retain the existing test that direct writes are ignored.

No store runtime implementation changes are included.

@changeset-bot

changeset-bot Bot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 31c01c8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
solid-js Patch
test-integration Patch
@solidjs/web Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed-hq

codspeed-hq Bot commented Sep 3, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 136 untouched benchmarks


Comparing GabbeV:proposal/remove-store-readonly (31c01c8) with next (ff96a67)

Open in CodSpeed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant