Lesson 28 · Module 4.5 — Library-grade patterns
create<State>()(...) — the double callEvery Zustand example has the same odd shape: create<State>()(...) —
you pass the type, then call twice, with an empty () in the middle. It looks
like a typo, but two separate TypeScript limits are stacked here. First, the state type
T is un-inferable — it sits in both input and output positions of your store
function — so you must annotate it by hand. Second, and this is what the empty
() is for: TypeScript won't let you annotate one type parameter while inferring
the others, so currying splits them — the first call annotates the state, the second infers the rest
(the middleware).
When you see a function called as fn<T>()(arg) — a generic + empty call +
real call — read it as "annotate the type I must pin, then infer the rest from the
argument". The first call locks the type you supply; the second call's argument is checked
against it — and crucially, any other type parameters are still inferred from that argument,
which one combined call can't do (TypeScript makes you pass all type arguments or none).
Strip Zustand down to its essence. The store function both returns the state and
reads it back via get:
declare const create: <T>(f: (get: () => T) => T) => T;
const x = create((get) => ({
foo: 0,
bar: () => get(),
}));
// x is inferred as `unknown`, NOT { foo: number; bar: () => ... }
Why unknown? To infer T, TS matches the object you return against
T. That object is { foo: 0, bar: () => get() }, and get() is
typed T — so its type is { foo: number; bar: () => T }. Now TS is stuck
solving T = { foo: number; bar: () => T }: T defined in terms of itself. The
one piece that could pin it down — bar's return — is just T again, so the
definition loops with no concrete value to start from. You never make a T here;
you only read one back out through get and hand it along — so there's nothing for TS to read
the type off of, and it falls back to unknown. (The jargon for "T sits in both
an output and an input like this" is invariant, and invariant type parameters can't be
inferred.)
T — then curry to keep the rest inferredThe actual fix for that unknown is annotating the state type by hand,
not the currying. In our minimal single-T example, even
create<BearState>((get) => …) in one call would do it — you supplied
T, so there's nothing left to infer.
So why the extra ()? Because the real create has more type
parameters than the state alone — the middleware mutator types — and you want to
annotate the state while letting those infer from your initializer. TypeScript won't allow that in one
call: you pass all type arguments or none
(microsoft/TypeScript#10571).
Currying splits the two groups so each call's type-argument list is all-or-nothing on its own.
See it in miniature — a two-parameter function where you must write the first type but want the second inferred:
declare function build<State, Plugin>(
f: (get: () => State) => State, // State is only passed through, never made → can't be inferred (like T)
plug: (s: State) => Plugin, // Plugin is inferable from plug
): Plugin;
build(f, plug); // ❌ State can't be inferred → unknown
build<MyState>(f, plug); // ❌ "Expected 2 type arguments, but got 1" — you'd have to write Plugin too
One call traps you: infer both or write both, never mix. Curry it — give each function its own type-parameter list — and the deadlock breaks:
declare function build<State>(): <Plugin>(
f: (get: () => State) => State,
plug: (s: State) => Plugin,
) => Plugin;
build<MyState>()(f, plug); // ✅ State annotated in call 1, Plugin inferred in call 2
Zustand's real create is this exact shape — the state annotated up front, the
middleware mutators inferred from your initializer:
// shape of the curried create (simplified):
declare const create:
<T>() => (f: StateCreator<T>) => UseBoundStore<StoreApi<T>>;
// so you call it in two steps:
interface BearState { bears: number; increase: (by: number) => void; }
const useBearStore = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((s) => ({ bears: s.bears + by })),
}));
The empty()is the first call — it pinsT = BearStatewhile leaving the middleware types to be inferred from your(set) => ({...})in the second call. By the time that creator is checked,Tis fixed, so TS just verifies your object matchesBearStateand typessetfor you.
StateCreator<T>The argument type is StateCreator. Read it as "a function you write that receives
set and get and returns the initial state":
// simplified — the middleware mutator params omitted
type StateCreator<T> = (
set: (partial: Partial<T> | ((s: T) => Partial<T>)) => void,
get: () => T,
) => T;
With T already locked to BearState, every piece resolves:
set accepts a partial BearState, get() returns a full
BearState, and your returned object must be a BearState. The
invariance that broke single-call inference is now harmless — you supplied T, so
nothing has to be inferred from a position that depends on itself.
For a lone state type, create<BearState>((set) => …) would be
enough — annotating T is the real fix. The empty () earns its keep the
moment you add middleware (persist, immer, devtools): each
brings extra type parameters you want inferred, and TypeScript can't annotate the state
while inferring those in one call. The () is what keeps the middleware types inferred —
that's the feature, not noise.
Recall, don't re-read. (Answers reveal on click.)
create((get) => …) infer the state as unknown?() in create<State>()(...) doing?T = BearState is fixed, what does the StateCreator<T> argument require?The reasoning is laid out in Zustand's own
TypeScript guide (see “Why can't we
just infer the type from the initial state?” — the invariance explanation). Best way to
feel it: paste the minimal declare const create example into the
TS Playground, hover x to see
unknown, then add a leading <T>() currying step and watch the
type resolve.
See a generic called as fn<T>()(arg) anywhere and unsure why? Paste it and
ask “is this currying so one type param can be annotated while the rest still infer?” Next lesson
(4.6) is the capstone — it pulls every mental model from Modules 0–4 together: types are the new
regex.