-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
80: Building a Type-Safe State Management Store
A few years ago, I was mentoring a junior dev on a large dashboard project. We had a custom state store that was essentially just a big object wrapped in a class. One afternoon, he renamed user.displayName to user.fullName in the state interface. He ran a global search-and-replace, felt confident, and pushed the code. But he missed one specific reducer—a legacy piece of code tucked away in a notification handler. The app didn't crash; it just silently stopped rendering the user's name in the top-right corner. We spent two hours debugging a "ghost" bug that TypeScript should have caught in milliseconds.
The problem was that our store was too permissive. It accepted any "action" object and returned a state that was cast as any internally. When you're building your own state management logic, the goal isn't just to store data; it's to create a system where it is mathematically impossible to dispatch an action that the store doesn't understand, or to update the state with a value of the wrong type.
Defining the State Blueprint with Generics
To avoid the "silent failure" scenario, we need a store that is generic over its state. If we hardcode the state type, the store is only useful for one feature. If we use any, we're back to square one. Instead, we define a Store class that takes a type parameter T.
class Store<T> {
private state: T;
private listeners: ((state: T) => void)[] = [];
constructor(initialState: T) {
this.state = initialState;
}
getState(): T {
return this.state;
}
// We'll refine this update method in a moment
update(newState: Partial<T>): void {
this.state = { ...this.state, ...newState };
this.listeners.forEach(listener => listener(this.state));
}
subscribe(listener: (state: T) => void) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
}
By using T, the store now knows exactly what getState() returns. If T is a UserPreferences interface, you get autocomplete and type checking everywhere you touch the state. I personally prefer Partial<T> for simple updates, but for complex state, that's not enough—we need a way to handle specific logic based on the intent of the change.
Enforcing Logic with Discriminated Unions
This is where most developers trip up. They create an Action interface with optional fields, which leads to a mess of if (action.type === '...') blocks where TypeScript still isn't sure what the payload is. The secret is the Discriminated Union. You define a set of specific action types, each with its own required payload.
type CartAction =
| { type: 'ADD_ITEM'; payload: { id: string; price: number } }
| { type: 'REMOVE_ITEM'; payload: { id: string } }
| { type: 'CLEAR_CART' };
interface CartState {
items: { id: string; price: number }[];
total: number;
}
Now, when you write your reducer, TypeScript uses "type narrowing." Once you check if action.type === 'ADD_ITEM', the compiler knows for a fact that action.payload contains an id and a price. It won't even let you try to access payload on a CLEAR_CART action because it knows that action has no payload. It's a safety net that catches bugs before you even hit "Save."
Bridging the Store and the Reducer
To tie this all together, we can extend our store to accept a reducer function. This separates the mechanism of storing data from the logic of how that data changes. I like to think of the store as the vault and the reducer as the only person with the key who knows how to reorganize the contents.
type Reducer<T, A> = (state: T, action: A) => T;
class TypedStore<T, A> {
private state: T;
private reducer: Reducer<T, A>;
private listeners: ((state: T) => void)[] = [];
constructor(initialState: T, reducer: Reducer<T, A>) {
this.state = initialState;
this.reducer = reducer;
}
dispatch(action: A): void {
this.state = this.reducer(this.state, action);
this.listeners.forEach(l => l(this.state));
}
getState(): T {
return this.state;
}
subscribe(listener: (state: T) => void) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
}
With this architecture, you've built a miniature version of Redux or Vuex, but it's tailored exactly to your needs and fully type-safe. You can't dispatch a random string or an object with the wrong properties; the compiler simply won't allow it.
📋 Practical Task
Exercise: Building a Type-Safe Shopping Cart Manager
Your task is to implement a type-safe store for a shopping cart. You need to ensure that the state cannot be updated with invalid data and that actions are strictly typed.
- Define the State: Create a
CartStateinterface withitems(an array of objects containingid: stringandquantity: number) andcouponCode(a string or null). - Define the Actions: Create a discriminated union
CartActionwith three types:'UPDATE_QUANTITY': must include a payload withidandquantity.'APPLY_COUPON': must include a payload with thecodestring.'RESET_CART': no payload.
- Implement the Reducer: Write a function
cartReducerthat takes the current state and aCartAction` and returns the new state. Ensure you use aswitchstatement to narrow the action types. - Instantiate the Store: Create an instance of the
TypedStore(using the class logic from the lesson) passing in an initial state and your reducer. - Test the Safety: Try to dispatch an action with a missing payload property (e.g.,
UPDATE_QUANTITYwithout anid) and verify that TypeScript throws a compile-time error.
There are no comments for now.