Week 3: TypeScript
A comprehensive guide to mastering TypeScript, covering mental models, configuration best practices, type-driven development patterns, generics, utility types, and common pitfalls. Explores the transition from runtime vs compile-time thinking, discriminated unions for state management, and practical insights for building production-grade applications.
I still remember the anxiety of my early days working with the MERN stack. I would write a function, confident in its logic, deploy it, and then wait.
In the JavaScript world, you are effectively flying blind. You write a function that expects a User object, but absolutely nothing stops a junior developer—or even yourself two weeks later—from passing it a string, a null, or a completely unrelated object.
You generally won't know something is wrong until the code is actually running in the browser, crashes, and a customer complains.
The Reality Check:
When you are dealing with critical systems or payment integrations, "undefined is not a function" isn't just a nuisance; it's a failure of the contract.
If my previous work has taught me anything, it's that understanding the chaos of the JavaScript Runtime is only step one. Step two is taming it. That is where TypeScript comes in. It changes the game by introducing Static Analysis, allowing us to catch bugs while we are typing, not while the user is clicking.
However, I see too many developers learning TypeScript the wrong way. They treat it as a hindrance, a tool that just yells at them with red squiggly lines, and they sprinkle any everywhere just to shut it up. In this post, I want to share how I shifted my mental model from simply writing syntax to actually thinking in types.
1. The Mental Model: Runtime vs. Compile Time
To truly master TypeScript, I had to accept one fundamental truth that trips up almost every developer transitioning from standard JavaScript:
💡 Core Concept: TypeScript does not exist in the browser.
Browsers like Chrome, Firefox, and Safari only understand JavaScript; they have absolutely no idea what an interface or a generic is. This leads to what I call the "Erasure Process".
TypeScript is strictly a compiler. Its job is to read your .ts code, check for logic errors via static analysis, and then erase all strict type information to output clean, standard .js code that the browser can execute.
The "Junior" Mistake ❌
I have seen this specific error more times than I can count in code reviews. A developer tries to use a TypeScript type as a value at runtime.
interface User {
id: string;
role: 'admin' | 'guest';
}
function checkUser(u: User) {
// 🛑 ERROR: 'User' only refers to a type, but is being used as a value here.
if (u instanceof User) {
console.log('Is a user');
}
}
This code fails spectacularly. Why? Because User is an interface. Once the code compiles to JavaScript, the interface keyword is deleted. It literally does not exist in runtime memory.
Distinguishing between what exists at Compile Time (Types) versus Runtime (Values) was the first major hurdle I had to clear to move toward a "Senior" level understanding.
2. The Configuration: The Contract We Sign
In every project I start, the heart of the project is tsconfig.json. This file tells the compiler exactly how strict it should be.
🛑 Warning: Many tutorials will tell you to just copy-paste a standard configuration. Do not do this.
You need to understand what you are agreeing to. If you turn off strict mode, you aren't writing TypeScript; you are writing "JavaScript with Hints".
Here's the configuration I use for most of my projects:
{
"compilerOptions": {
/* 1. The Output */
"target": "ES2020", /* Convert newer syntax to this JS version */
"module": "ESNext", /* Use modern import/export syntax */
"lib": ["DOM", "DOM.Iterable", "ESNext"],
/* 2. The Strictness (The Safety Net) */
"strict": true, /* Enable all strict type-checking options */
"noImplicitAny": true, /* Error on implied 'any' types */
"strictNullChecks": true, /* null/undefined are distinct types */
/* 3. The Developer Experience */
"skipLibCheck": true, /* Skip type checking of node_modules */
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src"],
"exclude": ["node_modules"]
}
Critical Flags I Never Compromise On
strict: true: This is non-negotiable. It turns on a family of checks that safeguards the entire codebase.strictNullChecks: In loose mode (the default in older setups),nullis a valid value for every type. You could assignnullto anumbervariable without error. With this flag enabled,numbermeans only a number.
If I want to allow a null value, I must explicitly write number | null.
This specific flag prevents the infamous "Cannot read property of undefined" error.
3. Thinking in Types: Implementation Walkthrough
Once the environment is set up, the actual coding requires a shift in how we view data structures.
The Great Debate: Interface vs. Type
You can define object shapes in two ways: Interfaces or Type Aliases.
- Method A: Interfaces
Interfaces are "open," meaning they can be merged. If you declare
interface Usertwice, TypeScript merges them into a single definition. - Method B: Type Aliases Types are "closed" and cannot be re-opened.
type User = {
id: string;
name: string;
};
In 2025, the community has largely settled on Types for almost everything because they are more flexible—they can handle Unions and Primitives, which Interfaces cannot. However, Interfaces are slightly faster for the compiler to check and are better for defining public library APIs.
My personal recommendation:
- Use
interfacefor Objects that define a clear entity (like aUser,Post, orProduct). - Use
typefor Unions, Functions, and complex utility logic.
Structural Typing (Duck Typing)
Coming from languages like Java or C#, you might be used to Nominal Typing (checking by name). TypeScript uses Structural Typing (checking by shape).
interface Ball { diameter: number; }
interface Sphere { diameter: number; }
let ball: Ball = { diameter: 10 };
let sphere: Sphere = { diameter: 20 };
ball = sphere; // ✅ This works!
Even though ball was defined as a Ball and sphere as a Sphere, TypeScript allows the assignment because their structure is identical. If it walks like a duck and quacks like a duck, TypeScript says it's a duck.
Discriminated Unions (The Senior Pattern)
This is perhaps the most important pattern I use for Redux, Context, or any State Management. Imagine handling an API request. It can be Loading, Success, or Error.
The Junior Way (Optional Soup) ❌
interface State {
status: 'loading' | 'success' | 'error';
data?: User; // Optional because it might not exist yet
error?: string; // Optional because it might be success
}
The problem here is that you can have a status of 'success' but data is undefined. TypeScript won't catch this ambiguity.
The Senior Way (Discriminated Union) ✅
type LoadingState = { status: 'loading' };
type SuccessState = { status: 'success'; data: User };
type ErrorState = { status: 'error'; error: string };
type State = LoadingState | SuccessState | ErrorState;
This is superior because of Narrowing.
Type Narrowing (The Art of if)
When you have a Union, you must narrow the type before using specific properties. TypeScript analyzes your control flow to do this.
Using the State type above:
function handleState(state: State) {
if (state.status === 'success') {
// BOOM: TS knows 'state' is 'SuccessState'.
// It allows access to 'state.data'.
console.log(state.data.name);
} else if (state.status === 'error') {
// TS knows 'state' is 'ErrorState'.
console.log(state.error);
} else {
// TS knows 'state' is 'LoadingState'.
// Accessing state.data here would be a compile error.
}
}
This makes "Impossible States" impossible. You literally cannot write code that accesses data when the request failed.
4. Production Insights: Generics and Utilities
Generics are often the hurdle that filters Juniors from Seniors. Think of a Generic as a Function Argument, but for Types.
Why We Need Generics
Imagine a function that returns the first element of an array. Without generics, we lose information:
function getFirst(arr: any[]): any {
return arr[0];
}
const num = getFirst([1, 2, 3]); // 'num' is any. We lost type safety. ❌
With Generics, we capture the type:
function getFirst<T>(arr: T[]): T {
return arr[0];
}
const n = getFirst([1, 2, 3]); // TS infers T is 'number'. 'n' is number. ✅
The <T> captures the type of the array passed in and uses it to type the return value. We can even constrain generics. If we want a function that only works on objects with a length property, we can write:
function logLength<T extends { length: number }>(item: T) {
console.log(item.length);
}
Utility Types: The Standard Library
TypeScript ships with built-in Generic Helpers that I use daily in production:
Partial<T>: Makes all properties optional. Great for "Update" forms (e.g., updating just the email, not the name).Pick<T, Keys>/Omit<T, Keys>: Selects or removes specific keys.Record<Keys, Type>: A cleaner syntax for objects with dynamic keys, replacing messy index signatures.
5. The Danger Zones: any vs unknown vs as
The any Demon 👹
Using any turns off the TypeScript compiler for that variable. It is a virus. If you pass an any variable to a function, that function accepts it, and the return type might become any, spreading through your codebase.
The Rule: Avoid any at all costs.
The unknown Solution
unknown is the type-safe counterpart to any. It says: "I don't know what this is yet, but I won't let you use it until you verify what it is."
function process(value: unknown) {
// value.toUpperCase(); // 🛑 Error: Object is of type 'unknown'.
if (typeof value === "string") {
console.log(value.toUpperCase()); // ✅ Allowed after checking.
}
}
I use unknown strictly for API responses or JSON parsing where the structure is legitimately uncertain.
Advanced: Type Assertions (as)
Sometimes, you know more than the compiler. For example, document.getElementById returns HTMLElement | null. It doesn't know it's a <canvas>.
const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;
By using as, you force the compiler to treat it as a specific type.
⚠️ Danger: This is dangerous. If you lie to the compiler, the runtime will crash. Only use as when you are 100% certain.
Actionable Summary
By the end of this week, you should refuse to write plain JavaScript ever again. The transition from "JavaScript with hints" to "Thinking in Types" is what allows us to build scalable, resilient applications.
Here is the checklist I apply to my own work:
- Configure Strictness: Ensure
strict: trueandstrictNullChecksare on. - Inference vs. Explicit: Let TS infer simple variables; use explicit types for Function Arguments and Return Types.
- Narrowing: Use Discriminated Unions for state management (Loading/Success/Error) to prevent impossible states.
- Generics: Use them to create reusable components and functions that preserve type information.
- Safety: Use
unknowninstead ofany, and only useasassertions when absolutely necessary.
TypeScript serves as a contract between the developer writing the function and the developer using it. When we honor that contract, we stop flying blind.