I was thinking how to make stores less magical and was thinking through this. Not sure if its a good idea, but trying to show through types if this is a store instead of just a plain object. Maybe we want it to be transparent but was just an idea. On talking with AI I kinda realized the problem is when you pass just a part of the store around. If you are using the whole store you probably know and you can't really highlight leaf reads
Below is AI generated:
Problem
Stores are indistinguishable from plain data in the type system. Store<T> is a bare Readonly<T> alias, and the store-ness evaporates almost immediately:
- For array stores it's gone at the first binding:
Readonly<Todo[]> normalizes to readonly Todo[], so const [todos] = createStore<Todo[]>(...) hovers with no trace of "store" anywhere.
- Nested views lose it structurally:
todos[0], store.user, a .filter() element, and a <For> item all type as plain Todo — even though at runtime every one of them is a live reactive proxy.
- Nothing stops a store view from flowing silently into a component typed with a plain shape (
function Test(props: { todo: Todo }) accepts todos[0]), after which the receiving code has no way to know it's holding reactive state.
- Type-aware tooling (lint rules for store destructuring/untracked reads, editor highlighting of store reads) has no reliable anchor: alias names don't survive
Omit/Pick, generic inference, or mapped types.
Proposal
Make Store<T> a deep phantom brand, and give nested views their own name:
declare const STORE_BRAND: unique symbol;
export interface StoreBrand {
readonly [STORE_BRAND]?: true; // optional: labels, never rejects
}
type StoreValue<V> = [V] extends [NotWrappable]
? V
: IsStoreType<V> extends true
? V
: StorePart<V>;
/** A nested view INTO a store — live and reactive, no setter of its own. */
export type StorePart<T> = { readonly [K in keyof T]: StoreValue<T[K]> } & StoreBrand;
export type Store<T> = { readonly [K in keyof T]: StoreValue<T[K]> } & StoreBrand;
Store and StorePart are structurally identical (mutually assignable, same brand); the distinct name exists purely so hovers tell you what you hold — the root you created, or a part of one.
What you see
const [todos] = createStore<Todo[]>([...]);
todos // Store<Todo[]>
todos[0] // StorePart<Todo> (was: Todo)
todos.filter(x => !x.completed)
// StorePart<Todo>[] (was: Todo[] — brand survives array methods)
<For each={todos}>{todo => ...}
// todo: StorePart<Todo> (via T[number] — no For changes needed)
function TodoItem(props: { todo: StorePart<Todo> }) { ... }
// props.todo.title — reads keep their identity inside the child
What it catches
Array-bearing shapes now refuse to flow into plain mutable-typed slots (readonly-array variance):
function Legend(props: { data: { rows: Row[] } }) { ... }
<Legend data={state} /> // ❌ error: Store<{rows: Row[]}> not assignable — rows is a readonly view
// ✅ declare it { data: Store<{rows: Row[]}> }
Because the brand is optional, transparency is preserved everywhere it must be: plain data still assigns into Store<T> positions (seeds, tests, fixtures), and Store<T> still flows anywhere Readonly<T> is accepted.
What it enables downstream
The brand property is structurally probeable by the checker API (getProperties() → __@STORE_BRAND…) where alias names die — the anchor for:
- an eslint-plugin-solid rule: "store view passed into a plain-typed prop / destructured / read untracked" (covers the primitive-only shapes that structural typing can't reject);
- semantic-token editor highlighting of store reads, Svelte-runes style. A working proof-of-concept analyzer (TS API, ~70 lines: brand probe + access-chain propagation + leaf/branch classification) already enumerates every store read site in the todos example with exact positions.
Trade-offs and findings
keyof Store<T> includes the phantom symbol, so it is not extends string; use keyof T or keyof S & string. (This same fact is what makes the brand discriminable — T extends StoreBrand is useless because optional-prop assignability passes everything; a keyof probe discriminates perfectly.)
- Primitive-only shapes still assign to plain types (
Store<Todo> → Todo when Todo has no array members). Structural typing cannot reject supertypes; that residue is the lint rule's job.
- ⚠️
X | Store<X> parameter unions crash tsc (stack overflow) when the argument is an object literal with a this-typed getter ({ base: 2, get double() { return this.base * 2 } } — real case in the patch-channel suite). Union inference traverses the mapped type against the literal's this-type and recurses. NoInfer does not save it. The fix shipped in the branch: plain-arm overload first, Store<T> fallback overload second — the fallback only relates a seed to the mapped type after the plain arm rejected it, which getter literals never are. This replaced every NoFn<T> | Store<NoFn<T>>-style union across createStore / createProjection / createOptimisticStore / hydration wrappers / server entry.
- A handful of internal truth-casts where generic plumbing hands the store view to draft-typed sinks (projection/optimistic setters; server pending-proxy and slot plumbing — server stores are raw objects and type as plain
T internally).
- Also evaluated and rejected: deep-readonly-style leaf flavoring (
number & StoreLeaf) — the label follows copies into dead locals and lies; and a required (nominal) brand — breaks seeds and every structural producer.
Implementation
Branch: brenelz/solid@store-brand-types (commit brenelz/solid@3ef6afe3) — 15 files, +207/−50, including the signature restructures and seven test files whose store-holding variables now declare Store<…> (the annotation discipline the brand promotes).
Verification: signals typecheck at the exact pre-existing error baseline (25 errors, zero new, no compiler crash), 1400 runtime tests green, declaration emit clean, solid types + test-types (DOM and no-DOM) clean.
Happy to open a PR if there's appetite.
I was thinking how to make stores less magical and was thinking through this. Not sure if its a good idea, but trying to show through types if this is a store instead of just a plain object. Maybe we want it to be transparent but was just an idea. On talking with AI I kinda realized the problem is when you pass just a part of the store around. If you are using the whole store you probably know and you can't really highlight leaf reads
Below is AI generated:
Problem
Stores are indistinguishable from plain data in the type system.
Store<T>is a bareReadonly<T>alias, and the store-ness evaporates almost immediately:Readonly<Todo[]>normalizes toreadonly Todo[], soconst [todos] = createStore<Todo[]>(...)hovers with no trace of "store" anywhere.todos[0],store.user, a.filter()element, and a<For>item all type as plainTodo— even though at runtime every one of them is a live reactive proxy.function Test(props: { todo: Todo })acceptstodos[0]), after which the receiving code has no way to know it's holding reactive state.Omit/Pick, generic inference, or mapped types.Proposal
Make
Store<T>a deep phantom brand, and give nested views their own name:StoreandStorePartare structurally identical (mutually assignable, same brand); the distinct name exists purely so hovers tell you what you hold — the root you created, or a part of one.What you see
What it catches
Array-bearing shapes now refuse to flow into plain mutable-typed slots (readonly-array variance):
Because the brand is optional, transparency is preserved everywhere it must be: plain data still assigns into
Store<T>positions (seeds, tests, fixtures), andStore<T>still flows anywhereReadonly<T>is accepted.What it enables downstream
The brand property is structurally probeable by the checker API (
getProperties()→__@STORE_BRAND…) where alias names die — the anchor for:Trade-offs and findings
keyof Store<T>includes the phantom symbol, so it is notextends string; usekeyof Torkeyof S & string. (This same fact is what makes the brand discriminable —T extends StoreBrandis useless because optional-prop assignability passes everything; a keyof probe discriminates perfectly.)Store<Todo>→TodowhenTodohas no array members). Structural typing cannot reject supertypes; that residue is the lint rule's job.X | Store<X>parameter unions crash tsc (stack overflow) when the argument is an object literal with athis-typed getter ({ base: 2, get double() { return this.base * 2 } }— real case in the patch-channel suite). Union inference traverses the mapped type against the literal's this-type and recurses.NoInferdoes not save it. The fix shipped in the branch: plain-arm overload first,Store<T>fallback overload second — the fallback only relates a seed to the mapped type after the plain arm rejected it, which getter literals never are. This replaced everyNoFn<T> | Store<NoFn<T>>-style union acrosscreateStore/createProjection/createOptimisticStore/ hydration wrappers / server entry.Tinternally).number & StoreLeaf) — the label follows copies into dead locals and lies; and a required (nominal) brand — breaks seeds and every structural producer.Implementation
Branch:
brenelz/solid@store-brand-types(commit brenelz/solid@3ef6afe3) — 15 files, +207/−50, including the signature restructures and seven test files whose store-holding variables now declareStore<…>(the annotation discipline the brand promotes).Verification: signals typecheck at the exact pre-existing error baseline (25 errors, zero new, no compiler crash), 1400 runtime tests green, declaration emit clean, solid
types+test-types(DOM and no-DOM) clean.Happy to open a PR if there's appetite.