Skip to content

Implementing Store Types ​

As noted in the previous section, our current implementation of defineStore and the store instance it returns lacks specific type information, relying on any. A key feature and major benefit of using Pinia is its excellent TypeScript support, which provides strong type checking and autocompletion. In this section, we will significantly improve our small-pinia by introducing robust type definitions for the Store type and implementing the necessary type-level logic to infer these types automatically from the user's defineStore setup function.

The Store Type in Detail (State, Getters, Actions) ​

Conceptually, a Pinia store instance combines three main types of properties that are exposed to the user when they call use...Store(): State, Getters, and Actions. At the type level, it's important to distinguish between the source types provided by the user's setup() function (its return type, let's call it SS) and the final exposed types on the store instance (Store).

Let's consider the types involved in the setup() function's return value and how they should be exposed on the final store instance:

  • State (S): In the setup() function, reactive state is typically defined using ref() or reactive(). Properties holding this state in the setup return will have types like Ref<T> or a reactive object type ({ count: Ref<number> }). However, when you access state properties on the final store instance (e.g., counter.count), Vue's reactivity system automatically unwraps the Ref, and you access the raw value (number in this case), not the Ref object itself. The Store type must reflect this unwrapped state type.
  • Getters (G): In the setup() function, getters are defined using computed(). Properties holding getters in the setup return will have the type ComputedRef<R>, where R is the type of the computed value. Similar to state, when accessed on the store instance (e.g., counter.doubleCount), you get the unwrapped computed value (number), not the ComputedRef object. The Store type must also reflect this unwrapped getter value type.
  • Actions (A): Actions are functions defined directly in the setup() function. When accessed on the store instance (e.g., counter.increment), they are called directly as methods. The Store type can directly include these function types without special unwrapping.

The .value problem we observed previously (trying to access counterSmallStore.count.value on the object returned by useStore()) illustrates this difference between the setup() return type (which contains Ref and ComputedRef) and the final Store instance type (where they are unwrapped). The type returned by useStore() (our Store type) needs to present state and getter values without requiring .value access, matching the behavior of official Pinia and reactive objects in templates.

For example, Back to the section, we implements the useCounsterSmallStore and call const counter = useCounterSmallStore(). We try to write the code like below

typescript
const counterSmallStore = useCounterSmallStore();
console.log(counterSmallStore.count.value);

You intuitively think that browser displays the 0 value. However browser console displays undefined value. If the return value of the defineStore function is used as the type in this time, we attach to the wrong defintion of type because we can't refer the value object. In fact, we refer the T type of ref<T>. So, we have to etract the T type of ref<T>. this logic is the same as computed propery. So, Pinia store has to split into the S and G type.

Therefore, the Store type definition needs to combine base properties with the unwrapped types derived from the state and getters defined in the setup function, along with the action types. A simplified structure for the Store type, ignoring utility methods for now, looks conceptually like this:

typescript
export type Store<
  Id extends string = string,
  S extends StateTree = {},
  G = {},
  A = {},
> = _StoreWithState<Id, S, G, A> & // Core properties ($id, _p)
  UnwrapRef<S> & // State properties (unwrapped)
  _StoreWithGetters<G> & // Getters (computed refs)
  A // Actions (functions become methods)
  • _SmallStoreWithState<Id, S, G, A> would hold base store properties like $id and a link back to the Pinia instance (_p), plus type definitions for utility methods like $dispose or $onAction that are present in the official Pinia Store type. We are omitting the implementation of these utilities in this guide, but their types would live here.
  • UnwrapRef<S> is a utility type from Vue that takes a type (like { count: Ref<number> }) and recursively unwraps all Ref properties, resulting in a type like { count: number }.
  • _StoreWithGetters<G> is a custom utility type we need to create that takes a type representing the getter properties from setup() (like { doubleCount: ComputedRef<number> }) and extracts their unwrapped value types, resulting in a type like { doubleCount: number }.
  • A represents the types of the action functions, which are included directly.

Applying Types in defineStore and Extraction Helpers ​

To connect the user's defineStore function's return type (SS) to our Store type structure (S, G, A), we need type-level helper utilities. These utilities will analyze the SS type and filter/categorize its properties into state-like (S), getter-like (G), and action-like (A) types based on whether a property is a Ref/Reactive, ComputedRef, or a function.

Let's look at the complete type definitions first, adding them to packages/small-pinia/types.ts.

typescript
// .types
import type { ComputedRef, UnwrapRef } from "vue";
import type { Pinia } from "./rootStore";

export type StateTree = Record<PropertyKey, any>;

export type _Method = (...args: any[]) => any;

/**
 * Base properties common to all store instances.
 */
export interface SmallStoreProperties<Id extends string> {
  /**
   * Unique identifier of the store.
   */
  $id: Id;

  /**
   * The Pinia instance this store belongs to.
   */
  _p: Pinia;
}

export interface _SmallStoreWithState<
  Id extends string,
  S extends StateTree,
  G,
  A
> extends SmallStoreProperties<Id> {
  // $dispose and $patch utils type
}

/**
 * Getters become readonly properties.
 */
export type _StoreWithGetters<G> = {
  readonly [K in keyof G]: G[K] extends ComputedRef<infer R> ? R : never;
};

export type Store<
  Id extends string = string,
  S extends StateTree = {},
  G = {},
  A = {}
> = _SmallStoreWithState<Id, S, G, A> & // Core properties ($id, _p)
  UnwrapRef<S> & // State properties (unwrapped)
  _StoreWithGetters<G> & // Getters (computed refs)
  A; // Actions (functions become methods)

export type _ExtractStateFromSetupStore<SS> = SS extends undefined | void
  ? {} // No state if setup returns nothing
  : {
      [K in keyof SS as SS[K] extends _Method | ComputedRef ? never : K]: SS[K];
    };
export type _ExtractActionsFromSetupStore<SS> = SS extends undefined | void
  ? {}
  : {
      [K in keyof SS as SS[K] extends _Method ? K : never]: SS[K];
    };
export type _ExtractGettersFromSetupStore<SS> = SS extends undefined | void
  ? {}
  : {
      [K in keyof SS as SS[K] extends ComputedRef ? K : never]: SS[K];
    };

Updating defineStore (packages/small-pinia/store.ts) ​

Now, we use these types in the defineStore function signature to tell TypeScript the expected types. The runtime logic in createSetupStore remains largely the same, as its job is to correctly merge the setup results onto a reactive object; the types we just defined describe the result of that merge at the type level.

typescript
// store.ts
export function defineStore<
  Id extends string,
  SS extends Record<PropertyKey, unknown>
>(id: Id, setup: () => SS) {
  const isSetupStore = typeof setup === "function";

  function useStore(): Store<
    Id,
    _ExtractStateFromSetupStore<SS>,
    _ExtractGettersFromSetupStore<SS>,
    _ExtractActionsFromSetupStore<SS>
  > {
    const pinia = inject(piniaSymbol, null);
    if (!pinia) {
      throw new Error("not call createPinia");
    }

    if (!pinia._s.has(id)) {
      if (isSetupStore) {
        /** setup method */
        createSetupStore(id, setup, pinia);
      } else {
        /**TODO: options method */
      }
    }

    const store = pinia._s.get(id)! as Store<
      Id,
      _ExtractStateFromSetupStore<SS>,
      _ExtractGettersFromSetupStore<SS>,
      _ExtractActionsFromSetupStore<SS>
    >;
    ...

You can get the proper type from useCounterSmallStore when accessing counter.count and counter.doubleCount and counter.increment!

Reference: You can check the code I write in the pr link https://github.com/KOBATATU/small-pinia/pull/3

Released under the MIT License.