Skip to content

TypeScript

Pinia provides first-class TypeScript support out of the box. This guide covers how to leverage TypeScript with Pinia for type-safe state management.

Basic Setup

Pinia is written in TypeScript and provides excellent type inference without additional configuration:

ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Eduardo'
  }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++
    }
  }
})

Type Inference

Pinia automatically infers types for your stores:

ts
const store = useCounterStore()

// TypeScript knows these types:
store.count // number
store.name // string
store.doubleCount // number (getter)
store.increment() // void (action)

Typing State

Basic State Types

ts
interface UserInfo {
  name: string
  email: string
  age?: number
}

export const useUserStore = defineStore('user', {
  state: (): { user: UserInfo | null; isLoading: boolean } => ({
    user: null,
    isLoading: false
  })
})

Complex State Types

ts
interface Product {
  id: string
  name: string
  price: number
  category: string
}

interface CartItem extends Product {
  quantity: number
}

interface CartState {
  items: CartItem[]
  total: number
  discountCode?: string
}

export const useCartStore = defineStore('cart', {
  state: (): CartState => ({
    items: [],
    total: 0,
    discountCode: undefined
  })
})

Typing Getters

Getters are automatically typed based on their return values:

ts
export const useCartStore = defineStore('cart', {
  state: (): CartState => ({
    items: [],
    total: 0
  }),
  getters: {
    // Automatically inferred as number
    itemCount: (state) => state.items.length,
    
    // Explicitly typed getter
    expensiveItems: (state): CartItem[] => {
      return state.items.filter(item => item.price > 100)
    },
    
    // Getter with parameter (returns a function)
    getItemById: (state) => {
      return (id: string): CartItem | undefined => {
        return state.items.find(item => item.id === id)
      }
    }
  }
})

Typing Actions

Actions are automatically typed based on their parameters and return values:

ts
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as UserInfo | null,
    isLoading: false
  }),
  actions: {
    // Async action with typed parameters
    async fetchUser(userId: string): Promise<UserInfo> {
      this.isLoading = true
      try {
        const response = await fetch(`/api/users/${userId}`)
        const user = await response.json() as UserInfo
        this.user = user
        return user
      } finally {
        this.isLoading = false
      }
    },
    
    // Action with typed parameters
    updateUser(updates: Partial<UserInfo>): void {
      if (this.user) {
        this.user = { ...this.user, ...updates }
      }
    }
  }
})

Composition API Stores

With the Composition API, TypeScript inference works seamlessly:

ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

interface Todo {
  id: string
  text: string
  completed: boolean
}

export const useTodoStore = defineStore('todo', () => {
  // State
  const todos = ref<Todo[]>([])
  const filter = ref<'all' | 'active' | 'completed'>('all')
  
  // Getters
  const filteredTodos = computed(() => {
    switch (filter.value) {
      case 'active':
        return todos.value.filter(todo => !todo.completed)
      case 'completed':
        return todos.value.filter(todo => todo.completed)
      default:
        return todos.value
    }
  })
  
  const completedCount = computed(() => 
    todos.value.filter(todo => todo.completed).length
  )
  
  // Actions
  function addTodo(text: string): void {
    todos.value.push({
      id: Date.now().toString(),
      text,
      completed: false
    })
  }
  
  function toggleTodo(id: string): void {
    const todo = todos.value.find(t => t.id === id)
    if (todo) {
      todo.completed = !todo.completed
    }
  }
  
  function removeTodo(id: string): void {
    const index = todos.value.findIndex(t => t.id === id)
    if (index > -1) {
      todos.value.splice(index, 1)
    }
  }
  
  return {
    // State
    todos,
    filter,
    // Getters
    filteredTodos,
    completedCount,
    // Actions
    addTodo,
    toggleTodo,
    removeTodo
  }
})

Store Type Utilities

Pinia provides utility types for extracting store types:

ts
import type { Store } from 'pinia'

// Extract the store type
type CounterStore = ReturnType<typeof useCounterStore>

// Or use the Store utility type
type CounterStoreType = Store<
  'counter',
  { count: number; name: string },
  { doubleCount: number },
  { increment(): void }
>

Typing Plugins

When creating plugins, you can type the context:

ts
import type { PiniaPluginContext } from 'pinia'

function myPlugin(context: PiniaPluginContext) {
  const { store, app, pinia, options } = context
  
  // Add typed properties to stores
  store.$customProperty = 'Hello'
}

// Extend the PiniaCustomProperties interface
declare module 'pinia' {
  export interface PiniaCustomProperties {
    $customProperty: string
  }
}

Advanced Typing Patterns

Generic Stores

ts
function createResourceStore<T>(name: string) {
  return defineStore(name, {
    state: () => ({
      items: [] as T[],
      loading: false,
      error: null as string | null
    }),
    actions: {
      async fetchItems(): Promise<T[]> {
        this.loading = true
        this.error = null
        try {
          const response = await fetch(`/api/${name}`)
          const items = await response.json() as T[]
          this.items = items
          return items
        } catch (error) {
          this.error = error instanceof Error ? error.message : 'Unknown error'
          throw error
        } finally {
          this.loading = false
        }
      }
    }
  })
}

// Usage
interface User {
  id: string
  name: string
  email: string
}

const useUserStore = createResourceStore<User>('users')

Strict State Types

ts
// Define strict interfaces
interface StrictUserState {
  readonly id: string | null
  readonly profile: UserProfile | null
  readonly preferences: UserPreferences
  readonly lastLoginAt: Date | null
}

interface UserProfile {
  readonly name: string
  readonly email: string
  readonly avatar?: string
}

interface UserPreferences {
  readonly theme: 'light' | 'dark'
  readonly language: 'en' | 'es' | 'fr'
  readonly notifications: boolean
}

export const useUserStore = defineStore('user', {
  state: (): StrictUserState => ({
    id: null,
    profile: null,
    preferences: {
      theme: 'light',
      language: 'en',
      notifications: true
    },
    lastLoginAt: null
  })
})

Typed Store Composition

ts
// Base store interface
interface BaseStore {
  loading: boolean
  error: string | null
}

// Mixin for loading states
function withLoading<T extends Record<string, any>>(store: T) {
  return {
    ...store,
    loading: false,
    error: null as string | null,
    
    setLoading(loading: boolean) {
      this.loading = loading
    },
    
    setError(error: string | null) {
      this.error = error
    }
  }
}

// Usage
export const useApiStore = defineStore('api', () => {
  const baseStore = withLoading({
    data: ref<any[]>([]),
    
    async fetchData() {
      this.setLoading(true)
      this.setError(null)
      try {
        // Fetch logic
      } catch (error) {
        this.setError(error instanceof Error ? error.message : 'Unknown error')
      } finally {
        this.setLoading(false)
      }
    }
  })
  
  return baseStore
})

Best Practices

1. Define Interfaces

Always define interfaces for complex state:

ts
// ✅ Good
interface UserState {
  user: User | null
  isAuthenticated: boolean
}

// ❌ Avoid
const state = {
  user: null,
  isAuthenticated: false
}

2. Use Type Assertions Carefully

ts
// ✅ Good - with validation
async fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`)
  const data = await response.json()
  
  // Validate the data structure
  if (isValidUser(data)) {
    this.user = data as User
  } else {
    throw new Error('Invalid user data')
  }
}

// ❌ Avoid - blind assertion
async fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`)
  this.user = await response.json() as User
}

3. Leverage Type Guards

ts
function isUser(obj: any): obj is User {
  return obj && typeof obj.id === 'string' && typeof obj.name === 'string'
}

// Usage in actions
actions: {
  setUser(userData: unknown) {
    if (isUser(userData)) {
      this.user = userData
    } else {
      throw new Error('Invalid user data')
    }
  }
}

4. Export Store Types

ts
// stores/user.ts
export const useUserStore = defineStore('user', {
  // ... store definition
})

// Export the store type for use in other files
export type UserStore = ReturnType<typeof useUserStore>

Common TypeScript Issues

Issue: State Type Inference

ts
// ❌ Problem: TypeScript can't infer the correct type
state: () => ({
  user: null, // inferred as null, not User | null
  items: [] // inferred as never[], not Item[]
})

// ✅ Solution: Explicit typing
state: (): { user: User | null; items: Item[] } => ({
  user: null,
  items: []
})

Issue: Getter Return Types

ts
// ❌ Problem: Complex getter without explicit return type
getters: {
  complexCalculation(state) {
    // Complex logic that TypeScript can't infer
    return someComplexCalculation(state)
  }
}

// ✅ Solution: Explicit return type
getters: {
  complexCalculation(state): CalculationResult {
    return someComplexCalculation(state)
  }
}

Next Steps

Now that you understand TypeScript with Pinia, explore:

  • Core Concepts - Understand Pinia's fundamental concepts
  • Plugins - Extend Pinia with custom functionality
  • Testing - Test your typed stores effectively

Released under the MIT License.