skip to content

In a Pinia option store, why must state() declare every key up front, and how do you type keys starting as null or []?

level: middleimportance: should knowfreq 36%

answer

  1. state comes from what state() returns
  2. arrow function for inference
  3. never[] and null literals
  4. cast or interface return type
  5. setup stores use ref generics

basics

~20 s

Pinia makes exactly the keys that state() returns into state, so an undeclared key never becomes state. Type empty starts with casts such as [] as string[] and null as Profile | null, or give state() an interface return type.

solid answer

~40 s

`state` is an arrow function returning a plain object; with `strict` (or at least `noImplicitThis`) TypeScript infers each field from its initial value. Empty starts infer badly: `[]` becomes `never[]` and `null` becomes `null`, so cast them (`[] as string[]`, `null as Profile | null`) or annotate the function, `state: (): PreferencesState => ({ … })`. Every key must appear, even one whose initial value is `undefined`, because Pinia turns exactly those keys into state. Assigning a key `state()` never declared is a type error and, at runtime, only a plain property of the store object, outside `$state`, devtools, `$subscribe` and `$reset`. Return a plain object; a class instance triggers a development diagnostic. In a setup store the same job is done with generics: `ref<string[]>([])`, `ref<Profile | null>(null)`.

code

ts · 22 lines
ts
import { defineStore } from 'pinia'

type Theme = 'light' | 'dark' | 'system'
interface Profile { name: string; locale: string }

interface PreferencesState {
  theme: Theme
  fontSize: number
  mutedChannels: string[]
  profile: Profile | null
  draftNote: string | undefined
}

export const usePreferencesStore = defineStore('preferences', {
  state: (): PreferencesState => ({
    theme: 'system',
    fontSize: 14,
    mutedChannels: [],
    profile: null,
    draftNote: undefined,
  }),
})

go deeper

for a junior

Remember that state() returns the full initial object, every key included, and that empty arrays and nulls need a type annotation.

for a middle

Explain why undeclared keys never become state, why [] infers never[], and the two ways to type state(): inline casts or an interface return type.

for a senior

Enforce complete, plain, fresh state in review, prefer an interface when the shape is shared, and use ref generics for setup stores.

for a principal

Set the team's convention for where state types live, balancing one explicit interface per store against lighter inline inference.

## Where Pinia's state comes from In an **option store** the state is whatever `state()` returns. When the store is first used, Pinia calls `state()`, stores the result under the store's id in the root state, and exposes each returned key on the store. That has two consequences: - **the returned object defines the shape**: a key that is not in it is not state; - **the returned object must be plain**: returning a class instance (`state: () => new Prefs()`) triggers the development diagnostic `PINIA_R1003`, which reports that the state must be a plain object. `state` is a function, not an object, so each pinia instance and each `$reset()` get a fresh object. Pinia's own typings describe it as an arrow function "to ensure correct typings". ## Why every key must be declared The docs are explicit: declare **every** state piece in `state()`, even when its initial value is `undefined`. Suppose a draft note is added on the fly: 1. TypeScript rejects `prefs.draftNote = 'x'`, because the store's type has no `draftNote`. 2. At runtime the assignment only adds a plain property to the store object. It is not in `prefs.$state` or in the root state. 3. So `$subscribe` never reports it, devtools does not show it, `$reset()` does not clear it, and server-side state serialisation does not carry it. Declaring `draftNote: undefined as string | undefined` fixes all of that at once. ## Inference and where it goes wrong With `strict` enabled (at minimum `noImplicitThis`), TypeScript infers the state type from the literal: | Initial value | Inferred type | Fix | |---|---|---| | `'system'` | `string` | `'system' as Theme` if you want a union | | `14` | `number` | none needed | | `[]` | `never[]` | `[] as string[]` | | `null` | `null` | `null as Profile \| null` | | `undefined` | `undefined` | `undefined as string \| undefined` | The `never[]` case is the classic surprise: `prefs.mutedChannels.push('promo')` fails to compile until the array is cast. ## Two ways to type it - **Casts inline**, field by field, as in the table. Short and local; the type lives next to the value. - **An interface for the return type**: `state: (): PreferencesState => ({ … })`. The interface documents the whole shape in one place, and a missing or misspelled key becomes a compile error inside `state()`. Either way, `$patch` then checks its argument against the state type: the object form accepts a deep partial, and the function form receives the full typed state. ## Setup stores A **setup store** has no `state()`; its state is the `ref()`s and `reactive()`s it returns, and their types come from those calls. The same empty-start problem is solved with generics: - `const mutedChannels = ref<string[]>([])` - `const profile = ref<Profile | null>(null)` - `const theme = ref<Theme>('system')` The declare-everything rule has a setup-store twin: state that is not returned from the setup function is not store state either. ## What to check in review 1. Every key the UI reads or writes appears in `state()`. 2. No field is typed `never[]` or bare `null`. 3. `state` returns a plain object built fresh on each call, never a shared constant or a class instance. 4. Unions (`Theme`) are cast or come from an interface, so invalid values fail to compile. ## Mistakes candidates make - **Adding a key later "because it is optional".** Optional state still has to be declared; give it `undefined` and a union type. - **Typing with `any` to silence `never[]`.** It compiles, but every later `push` and every consumer loses checking. Cast to the real element type instead. - **Writing `state` as a regular function that reads `this`.** Pinia calls `state()` with no store context, so `this` gives nothing useful; keep it an arrow that returns a literal, which also infers best. - **Mixing an interface and inline casts for the same field.** When the return type is annotated, the casts are redundant and can drift from the interface. The payoff of getting this right shows up everywhere else in the store: `$patch` objects are checked against the declared shape, a `$reset()` restores every declared key, and devtools and `$subscribe` see the whole state because nothing lives outside it.

  • What goes wrong when a Pinia option store's state() returns a class instance?
    Pinia expects a plain object and, in development, reports diagnostic `PINIA_R1003`: the state must be a plain object. Class instances do not fit the state tree's model: object patches only merge into plain objects, and serialising state for the server drops methods and prototypes. Keep behaviour in actions and getters, and state as plain data.
  • How do you give a Pinia setup store's empty list a type?
    With the generic on the call that creates it: `const mutedChannels = ref<string[]>([])`. Without it, `ref([])` infers `Ref<never[]>` and pushing a string fails to compile. The same applies to `ref<Profile | null>(null)` for data that is loaded later.

saying these in an interview costs you the question

  • You can add new state keys at runtime and Pinia tracks them like declared ones.
  • An empty array literal in state() is inferred as any[].
  • Keys whose initial value is undefined can be left out of state().
  • state can be a plain object instead of a function with no downside.
  • A class instance is a fine return value for state().