In a Pinia option store, why must state() declare every key up front, and how do you type keys starting as null or []?
answer
- state comes from what state() returns
- arrow function for inference
- never[] and null literals
- cast or interface return type
- setup stores use ref generics
basics
~20 sPinia 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 linesimport { 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
Remember that state() returns the full initial object, every key included, and that empty arrays and nulls need a type annotation.
Explain why undeclared keys never become state, why [] infers never[], and the two ways to type state(): inline casts or an interface return type.
Enforce complete, plain, fresh state in review, prefer an interface when the shape is shared, and use ref generics for setup stores.
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().