Type Pinia's defineStore: getters are written as functions but read as values, and state is mutable in actions but read-only in getters. ThisType does the work.
Getters become values, state flips between mutable and read-only depending on where you stand, and ThisType decides who sees what.
The target is a type-level version of Pinia's defineStore. No implementation needed, just types. The function takes a single object with four properties: id (a string), state (a function returning the store's state), getters (computed-style methods) and actions (methods that can mutate state). Getters are defined as functions but consumed as plain values, and inside a getter the state is read-only:
const store = defineStore({
// ...other required fields
getters: {
getSomething() {
return 'xxx'
}
}
})
store.getSomething // ok
store.getSomething() // errorActions stay callable functions. Their parameter and return types must survive intact so call sites are fully type-checked. Inside an action, this can read and mutate state and call other actions. Getters are visible there too, still read-only:
const store = defineStore({
// ...other required fields
actions: {
doSideEffect() {
this.xxx = 'xxx'
return 'ok'
}
}
})
const returnValue = store.doSideEffect()Create a type-level function whose types is similar to Pinia library. You don't need to implement function actually, just adding types.
This function receive only one parameter whose type is an object. The object contains 4 properties:
id - just a string (required)state - a function which will return an object as store's state (required)getters - an object with methods which is similar to Vue's computed values or Vuex's getters, and details are below (optional)actions - an object with methods which can do side effects and mutate state, and details are below (optional)When you define a store like this:
const store = defineStore({
// ...other required fields
getters: {
getSomething() {
return 'xxx'
}
}
})And you should use it like this:
[object Object]instead of:
[object Object]Additionally, getters can access state and/or other getters via this, but state is read-only.
When you define a store like this:
const store = defineStore({
// ...other required fields
actions: {
doSideEffect() {
this.xxx = 'xxx'
return 'ok'
}
}
})Using it is just to call it:
[object Object]Actions can return any value or return nothing, and it can receive any number of parameters with different types. Parameters types and return type can't be lost, which means type-checking must be available at call side.
State can be accessed and mutated via this. Getters can be accessed via this but they're read-only.
View on GitHub: https://tsch.js.org/1290
Change the following code to make the test cases pass (no type check errors).
👋 Lifetime-License is leaving on August 10, 2026
Get it now for $29
One-time payment. Lifetime access to all pro challenges.
The full solution:
type GetterValues<TGetters> = {
readonly [K in keyof TGetters]: TGetters[K] extends () => infer Result
? Result
: never
}
declare function defineStore<TState, TGetters, TActions>(store: {
id: string
state: () => TState
getters: TGetters & ThisType<Readonly<TState> & GetterValues<TGetters>>
actions: TActions &
ThisType<TState & GetterValues<TGetters> & TActions>
}): TState & GetterValues<TGetters> & TActionsThe challenge comes down to one question asked three times: what is visible, and what is mutable, through this or the store in a given context.
defineStore<TState, TGetters, TActions> gets all three type arguments inferred from the object literal you pass in: TState from the return type of state, and TGetters/TActions from the literal shapes of those objects. For the test store:
// TState = { num: number; str: string }
// TGetters = { stringifiedNum: () => string; parsedNum: () => number }
// TActions = { init: () => void; increment: (step?: number) => void; ... }GetterValues: functions in, values outGetters are written as functions but read as values. store.stringifiedNum is a string, and store.stringifiedNum() must be an error. GetterValues does the conversion with a mapped type:
type Example = GetterValues<{ stringifiedNum: () => string }>
// evaluates to: { readonly stringifiedNum: string }For each key, TGetters[K] extends () => infer Result extracts the function's return type. Two details matter here:
readonly modifier on the mapped type is not decoration. It is what makes this.parsedNum = 0 inside an action a compile error, which satisfies that @ts-expect-error in the tests. Getters are derived values; nobody gets to assign to them.stringifiedNum: string instead of a function, store.stringifiedNum() fails on its own: you can't call a string.ThisType: a different this per contextThisType<T> is an empty marker interface with special compiler support: when an object literal is contextually typed by X & ThisType<T>, every method in that literal gets this: T automatically. (This requires the noImplicitThis compiler option, which is on under strict; without it, ThisType has no effect.) The solution uses it twice, with deliberately different Ts.
Getters get ThisType<Readonly<TState> & GetterValues<TGetters>>:
Readonly<TState> lets a getter read this.num but makes this.num += 1 an error. The tests explicitly check that state is immutable from a getter's perspective.GetterValues<TGetters> lets getters compose: parsedNum reads this.stringifiedNum as an already-unwrapped string.this is the whole store, actions included.)Actions get ThisType<TState & GetterValues<TGetters> & TActions>:
TState, without Readonly: mutation like this.num += step is the whole point of an action.GetterValues<TGetters>: readable, but the mapped readonly still blocks assignment.TActions itself: actions can call each other, as init() does with this.reset() and this.increment().The store you hand back is the same intersection the actions see:
[object Object]State properties come through as-is (store.num is number), getters as unwrapped read-only values, and actions as untouched functions. Keeping TActions untouched is what preserves signatures exactly: increment(step = 1) is inferred as (step?: number) => void, so store.increment() and store.increment(2) both work while store.init(0), a call with an argument to a zero-parameter action, fails.
store.nopeStateProp / store.nopeGetter: the store is an exact intersection, not any or an index signature, so unknown properties are errors.const r = store.reset() with Equal<typeof r, true>: because TActions flows through inference untouched, even the literal return type true survives to the call site.store.setNum('3') fails while store.setNum(3) works: parameter types are preserved, not erased to any.this.num += 1 errors in a getter but this.num += step compiles in an action: same property, two different ThisType views.If you solved Simple Vue, this is the same trick with sharper edges. The mechanics of ThisType are not the hard part; deciding which capabilities each context gets to see is.
This challenge is originally from here.