Skip to content

Proposal: branded store types — deep Store<T> + StorePart<T> #3092

Description

@brenelz

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions