Mastering TypeScript: Interfaces, Generics, Unions Explained

If JavaScript works, why was TypeScript created?
JavaScript runs fine without TypeScript. It always has. So why did an entire typed superset of the language become the default choice for so many production codebases?
Because “runs fine” and “runs correctly” aren’t the same guarantee. JavaScript will happily let you call .toFixed() on undefined, pass a string where a number was expected, or misspell a property name — and say nothing about it until that exact line executes, often in production, often in front of a user. TypeScript exists to catch exactly that category of mistake before the code ever runs. This guide builds TypeScript from the ground up — types, interfaces, unions, generics, and the configuration and compilation process tying it all together — using real User, Product, and Order examples throughout.
1. Why TypeScript Exists
Problems with plain JavaScript in large applications
In a small script, JavaScript’s flexibility is a feature. In a large application — dozens of files, multiple developers, functions calling functions calling functions — that same flexibility means a typo in a property name, or a value of the wrong shape, can silently pass through several layers of code before finally causing a crash somewhere far from its actual source.
Runtime errors vs compile-time errors
A runtime error happens while the program is actually running — often in production, in front of a real user. A compile-time error is caught before the code ever runs, while you’re still writing it. TypeScript’s entire value proposition is shifting as many errors as possible from the first category into the second.
// Plain JavaScript — no error until this line actually runs
function getTotal(order) {
return order.total.toFixed(2);
}
getTotal({ totall: 42 }); // typo — crashes at runtime: "Cannot read properties of undefined"// TypeScript — the same typo is flagged immediately, while writing the code
function getTotal(order: Order): string {
return order.total.toFixed(2);
}
getTotal({ totall: 42 }); // Error: Property 'totall' does not exist on type 'Order'Benefits of static typing
- Mistakes like typos and wrong-shaped data get caught immediately, in the editor, not after deployment
- Function signatures become self-documenting — you can see exactly what a function expects and returns
- Refactoring becomes safer — renaming a property surfaces every place that needs updating, instead of silently breaking at runtime
How TypeScript improves developer productivity
Modern editors use TypeScript’s type information to power accurate autocomplete, inline documentation, and “jump to definition” — all of which get noticeably better once your code’s shapes are explicitly known.
TypeScript as a superset of JavaScript
Every valid piece of JavaScript is already valid TypeScript. TypeScript doesn’t replace JavaScript — it adds an optional type system on top of it, which is why adopting it incrementally, file by file, is realistic even in an existing JavaScript codebase.

2. Understanding Type Annotations
Adding types to variables
let username: string = "aarav";
let age: number = 21;
let isActive: boolean = true;The : type after a variable name is a type annotation — an explicit statement of what kind of value this variable is allowed to hold.
Function parameter types
function greet(name: string) {
return `Hello, ${name}!`;
}Calling greet(42) is now flagged immediately — 42 isn't a string, and TypeScript won't let that pass silently.
Function return types
function getTotal(order: Order): number {
return order.total;
}Annotating the return type ensures the function actually returns what it claims to — if the function body tried to return a string here, TypeScript would flag the mismatch.
Type inference
TypeScript doesn’t require annotating everything — it can often infer a variable’s type from its initial value:
let count = 5; // TypeScript infers `count` is a number, without an explicit annotationExplicit vs inferred types
A common, sensible convention: let TypeScript infer types for simple local variables, but explicitly annotate function parameters and return types — since those form the “contract” other code relies on, and inference has nothing to work from for a parameter with no default value.

3. Interfaces vs Type Aliases
What interfaces are
An interface describes the shape of an object — what properties it must have, and their types:
interface User {
id: number;
name: string;
email: string;
}What type aliases are
A type alias gives a name to any type — not just object shapes, but unions, primitives, or anything else:
type User = {
id: number;
name: string;
email: string;
};Similarities between them
For describing a plain object’s shape, interfaces and type aliases are nearly interchangeable — both examples above describe an identical User shape, and both support optional properties, extending/combining other types, and method signatures.
Differences between them
- Interfaces can be re-opened and extended later in the same codebase (declaration merging) — useful in some library and framework scenarios
- Type aliases can represent things interfaces can’t, like union types (
type Status = "active" | "inactive") or primitive aliases - Interfaces traditionally use
extendsto build on another interface; type aliases use&(intersection, covered in Section 5) for a similar effect
When to use interfaces
When describing the shape of an object — especially one that might be extended later, or represents a public API/contract (like a Product or User model) — interfaces are a natural, conventional choice.
Rule of thumb: Use an interface for object shapes that are part of a public API or may be consumed and extended by other libraries or teams, since interfaces support declaration merging. For application-internal object types, either an interface or a type alias is usually fine, so it’s often best to follow your project’s existing style or linting rules. If a type is only used within a single file, there’s usually no need to convert it from a type alias to an interface unless there’s a clear benefit.
When to use type aliases
When you need to describe something beyond a plain object shape — such as a union of specific string values, a function type, a tuple, or a combination of multiple types — type aliases are often the better choice, since interfaces cannot directly express those kinds of types.


4. Union Types
What union types are
A union type lets a value be one of several specified types, using the | symbol:
type OrderStatus = "pending" | "shipped" | "delivered" | "cancelled";Combining multiple possible types
function formatId(id: string | number): string {
return `ID-${id}`;
}id here is allowed to be either a string or a number — anything else is rejected.
Real-world use cases
An Order's status field is a textbook union type use case — it should only ever be one of a small, known set of specific string values, not an arbitrary string:
interface Order {
id: number;
status: "pending" | "shipped" | "delivered" | "cancelled";
}Handling unions safely
Because a union value could be any of its listed types, code that uses it often needs to narrow which specific type it’s dealing with before acting on type-specific behavior:
function printId(id: string | number) {
if (typeof id === "string") {
console.log(id.toUpperCase()); // safe: TypeScript knows id is a string here
} else {
console.log(id.toFixed(2)); // safe: TypeScript knows id is a number here
}
}



