Matt Pocock TypeScript Workflow: Level Up Your Code
Published September 18, 2026 · Editorial policy

TypeScript is one of the most polarizing tools in modern software engineering. To some, it is a safety net that catches regressions before they hit production. To others, it is an exercise in type-level gymnastics—a Byzantine system of generics, conditional types, and utility wrappers that slow down delivery and obfuscate business logic.
Enter Matt Pocock. Through his platforms like Total TypeScript, his open-source contributions, and his relentless focus on developer ergonomics, Pocock has fundamentally shifted how senior engineers approach the language. He doesn't treat TypeScript as a bureaucratic compiler hurdle; he treats it as a domain-specific language for code generation, architectural documentation, and autocomplete optimization.
If you are a Tier-1 developer looking to shave seconds off your feedback loop, eliminate "any" casts, and write type definitions that actually make sense, adopting the Pocock workflow is non-negotiable. Here is how to operationalize his methodologies in your daily development cycle.
1. Master the Mental Model: Types as Documentation, Not Validation
The foundational shift required to adopt a Matt Pocock-style workflow is changing how you view the TypeScript compiler. Most developers view the type system as a runtime validator that happens at compile time. Pocock treats types as an interactive documentation layer for your IDE.
When you write TypeScript, you are writing two programs simultaneously:
- The JavaScript program that runs in Node.js or the browser.
- The TypeScript program that runs entirely inside your editor's Language Server Protocol (LSP).
If your types are difficult to read, your LSP output will be difficult to read. If your types are properly structured, your editor becomes an execution-time playground that surfaces API contracts instantly.
Actionable Rule: Design for the Hover State
Never write a type definition just to satisfy the compiler. Write it so that when a junior engineer (or you, six months from now) hovers over a function call, the tooltip tells a complete story.
// Bad: Cryptic and opaque
type ProcessConfig = Record<string, string | number | boolean>;
// Good: Explicit, readable, and self-documenting via the LSP
interface DatabaseConfig {
host: string;
port: number;
ssl: boolean;
}
interface ApiConfig {
endpoint: string;
timeoutMs: number;
}
type AppConfig = {
db: DatabaseConfig;
api: ApiConfig;
};
When you hover over AppConfig in a modern editor, you see a neatly nested blueprint of your application architecture, not a generic key-value bucket.
2. Embrace satisfies: The Ultimate Ergonomic Upgrade
One of Pocock’s most frequently championed features is the satisfies operator, introduced in TypeScript 4.9. Before satisfies, developers faced a constant dilemma when typing configuration objects or dictionaries:
- Option A: Explicitly type the object. You get validation on your keys, but you lose the literal types of your values.
- Option B: Use
as constor let TypeScript infer the types. You keep the literal types, but you lose structural validation against a broader interface.
satisfies bridges this gap. It allows you to validate that an expression matches a type without changing the resulting inferred type of that expression.
The Problem: Loss of Literals
interface Routes {
[key: string]: string | (() => string);
}
// We want to type this routes object...
const routes: Routes = {
home: "/",
about: "/about",
id: () => "user-123",
};
// Error! Property 'length' does not exist on type 'string | (() => string)'
// TypeScript forgot that routes.home is specifically "/"
const homeLength = routes.home.length;
The Pocock Solution: Enter satisfies
type RouteConfig = Record<string, string | (() => string)>;
const routes = {
home: "/",
about: "/about",
id: () => "user-123",
} satisfies RouteConfig;
// TypeScript remembers that routes.home is EXACTLY "/"
const homeLength = routes.home.length;
By integrating satisfies into your configuration files, routing tables, and theme objects, you stop fighting the compiler for literal types while maintaining strict architectural boundaries.
3. Leverage Extract and Exclude for Domain Modeling
Senior developers often find themselves writing massive switch statements or mapping objects based on union types. When the underlying union changes, the compiler doesn't always warn you if you've missed a branch unless you are leveraging exhaustive checking.
Pocock heavily emphasizes narrowing union types using utility types like Extract and Exclude rather than writing custom type guards from scratch. This allows you to slice and dice your domain models cleanly.
Consider a state machine for an async operation:
type NetworkState =
| { status: "idle" }
| { status: "loading"; progress: number }
| { status: "success"; data: unknown }
| { status: "error"; error: Error };
If you need to write a function that handles only the active states (loading or error), avoid manually redefining types. Extract them directly from the source of truth:
type ActiveStates = Extract<NetworkState, { status: "loading" | "error" }>;
function logActiveState(state: ActiveStates) {
if (state.status === "loading") {
console.log(`Loading: ${state.progress}%`);
} else {
console.error(`Failed: ${state.error.message}`);
}
}
Why this improves workflow:
- Single Source of Truth: If
NetworkStateadds a new status tomorrow, your derived types update automatically. - DRY Types: You stop duplicating interface definitions across files.
4. Crafting Bulletproof Discriminated Unions
Discriminated unions are the bedrock of robust TypeScript architectures, but they are frequently implemented incorrectly. Pocock advocates for a strict pattern: every object in a union must share a common, literal property (the discriminant) that the compiler can use for control flow analysis.
The Anti-Pattern: Optional Properties Everywhere
// Fragile: Allows invalid states like loading with data
interface BadResponse {
isLoading: boolean;
isError: boolean;
data?: User;
error?: Error;
}
The Pocock Pattern: Strict Discriminated Unions
type Response<T> =
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function handleResponse<T>(res: Response<T>) {
switch (res.status) {
case "loading":
return "Loading...";
case "success":
// TypeScript automatically narrows `res` to have `data: T`
return res.data;
case "error":
// TypeScript automatically narrows `res` to have `error: Error`
return res.error.message;
}
}
To ensure you never miss a case in your switch statements, combine this with the never type for exhaustive checks:
function assertNever(x: never): never {
throw new Error(`Unexpected object: ${JSON.stringify(x)}`);
}
function handleResponseStrict<T>(res: Response<T>) {
switch (res.status) {
case "loading": return "Loading...";
case "success": return res.data;
case "error": return res.error.message;
default:
// If a new status is added to Response<T>, this line will fail to compile
return assertNever(res);
}
}
5. Refactoring the Feedback Loop: IDE First, Tests Second
A core tenet of the Matt Pocock philosophy is that your IDE should be your fastest test runner. If you have to wait for a unit test suite to tell you that you passed an incorrect string to a configuration object, your feedback loop is broken.
To optimize your local workflow:
1. Enable Strict Mode Flags
Never start a serious TypeScript project without these compiler flags in your tsconfig.json. They force you to write code that the language server can reason about effectively.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true
}
}
noUncheckedIndexedAccess is particularly game-changing. It forces you to account for the possibility that looking up an item in an array or object might return undefined, eliminating entire classes of runtime TypeError: Cannot read properties of undefined bugs.
2. Use ts-patch or Native IDE Diagnostics
Ensure your editor (VS Code, WebStorm, or Neovim via LSP) is configured to use the workspace version of TypeScript, not the global one. This ensures that the type definitions and quick fixes available to you match your CI pipeline precisely.
6. Practical Refactoring Exercise: From any to Elegant
Let’s apply these principles to a common legacy scenario: a generic event emitter function that accepts untyped payloads.
Legacy Code
function emitEvent(eventName: string, payload: any) {
// Event dispatch logic
window.dispatchEvent(new CustomEvent(eventName, { detail: payload }));
}
// Usage allows typos and invalid payloads silently
emitEvent("user-login", { userId: 123 });
Refactored Pocock-Style Workflow
First, map your application events as a dictionary where the key is the event name and the value is the payload type:
type ApplicationEvents = {
"user:login": { userId: string; timestamp: number };
"cart:update": { itemId: string; quantity: number };
"modal:close": undefined;
};
Next, implement a strongly typed wrapper using mapped types and keyof extraction:
function emitEvent<TEventName extends keyof ApplicationEvents>(
eventName: TEventName,
...args: ApplicationEvents[TEventName] extends undefined
? []
: [payload: ApplicationEvents[TEventName]]
) {
const [payload] = args;
console.emitting(`[${String(eventName)}]`, payload);
}
// IDE Autocomplete works perfectly!
emitEvent("user:login", { userId: "usr_123", timestamp: Date.now() });
// Compile Error: Property 'quantity' is missing
emitEvent("cart:update", { itemId: "item_99" });
// Compile Error: Expected 0 arguments, but got 1
emitEvent("modal:close", { unexpected: true });
This pattern eliminates runtime surprises, provides immaculate autocomplete for event names and payloads, and requires zero documentation comments.
Summary Checklist for Your Team
To bring your team up to the Matt Pocock standard of TypeScript proficiency, implement these operational changes this week:
- Audit your
tsconfig.json: Turn onnoUncheckedIndexedAccessandstrict. - Ban
anyvia ESLint: Replace it withunknownpaired with type guards, or leverage generics and utility types. - Adopt
satisfies: Sweep through configuration files and replace explicit type annotations withsatisfiesto preserve literal types. - Build Discriminated Unions: Refactor state management, API responses, and action handlers to rely on literal string discriminants and exhaustive
switchstatements. - Treat the Hover State as a Feature: Write your types so that reading the editor tooltip is more informative than scrolling through implementation files.
By shifting your perspective from "fighting the compiler" to "architecting with the LSP," you will write safer code faster, leaving complex type bugs dead on arrival inside your editor.


