Skip to main content
Madhukar
All Articles

Mastering TypeScript: Interfaces, Generics, Unions Explained

July 22, 202613 min read
web-developmenttypescriptsoftware-engineeringjavascript
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 annotation

Explicit 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 extends to 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
}
}

5. Intersection Types

What intersection types are

An intersection type, using the & symbol, combines multiple types into one — the result must satisfy all of them simultaneously, not just one.

Combining multiple type definitions

type Timestamped = {
createdAt: Date;
updatedAt: Date;
};

type Product = {
id: number;
name: string;
price: number;
};

type StoredProduct = Product & Timestamped;

A StoredProduct must now have every property from both Product and Timestamped combined — id, name, price, createdAt, and updatedAt, all required together.

Creating reusable type structures

This pattern — small, focused types combined with & — lets common structures (like Timestamped, or a shared Auditable shape with createdBy/updatedBy) be defined once and mixed into many different models without repeating those fields everywhere.

Practical examples

type WithId = { id: number };
type WithTimestamps = { createdAt: Date; updatedAt: Date };

type User = WithId & WithTimestamps & {
name: string;
email: string;
};

type Order = WithId & WithTimestamps & {
userId: number;
total: number;
status: "pending" | "shipped" | "delivered" | "cancelled";
};

6. Generic Functions

Why generics are needed

Imagine a function that simply returns the first item of an array. Writing it separately for arrays of numbers, strings, and User objects would mean duplicating the exact same logic three times, purely to satisfy specific types. Generics let you write it once, for any type, while still keeping full type safety.

Reusable type-safe functions

function getFirst<T>(items: T[]): T {
return items[0];
}

const firstUser = getFirst<User>(users); // T becomes User — return type is User
const firstProduct = getFirst<Product>(products); // T becomes Product — return type is Product

Generic parameters

<T> is a type parameter — a placeholder for "whatever type gets passed in when this function is actually called." TypeScript can usually infer T automatically from the arguments, without it needing to be written explicitly at the call site.

const firstUser = getFirst(users); // TypeScript infers T = User automatically

Generic constraints

Sometimes a generic function needs to guarantee its type parameter has at least certain properties — extends adds that constraint:

function getId<T extends { id: number }>(item: T): number {
return item.id;
}

Now getId can accept a User, a Product, or an Order — since all three have an id: number — but rejects anything that doesn't.

Real-world examples

function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}

const user = findById<User>(users, 42);
const order = findById<Order>(orders, 7);

The same findById logic works safely across completely different data models, without being rewritten for each one.

7. Understanding tsconfig.json

What tsconfig.json is

tsconfig.json is the configuration file that tells the TypeScript compiler how to treat your project — which files to include, which JavaScript version to compile to, and how strict the type checking should be.

Why TypeScript projects need it

Without it, every file would need its compilation options specified individually. tsconfig.json centralizes these settings once for the entire project, ensuring consistent behavior across every file and every developer working on it.

Common compiler options

{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"strict": true,
"outDir": "./dist",
"rootDir": "./src"
}
}

Strict mode

Setting "strict": true enables a whole group of stricter type-checking rules at once — including disallowing implicit any types and requiring null checks — generally considered best practice for catching the most real bugs, especially in new projects.

Target configuration

"target" controls which version of JavaScript the compiled output should use — a lower target produces more widely compatible (but sometimes more verbose) JavaScript, letting you write modern TypeScript while still supporting older environments.

Module configuration

"module" controls how the compiled JavaScript handles imports and exports — matching this to your actual runtime environment (a bundler, Node.js, or a browser) is essential for the compiled code to actually work correctly.

Project-wide settings

tsconfig.json also typically specifies which files to include or exclude (include, exclude), keeping the compiler focused only on your actual source code and not, for example, node_modules.

8. TypeScript Compilation Process

How TypeScript becomes JavaScript

The TypeScript compiler (tsc) reads your .ts files, checks all the types according to your tsconfig.json settings, and — if there are no type errors — outputs plain .js files with all the type annotations stripped away.

What happens during compilation

// input.ts
function greet(name: string): string {
return `Hello, ${name}!`;
}
// output.js — types are gone, logic remains
function greet(name) {
return `Hello, ${name}!`;
}

Why browsers cannot run TypeScript directly

Browsers (and Node.js) only understand plain JavaScript — they have no built-in knowledge of TypeScript’s type syntax. TypeScript must always be compiled down to ordinary JavaScript before it can actually run anywhere.

Build workflow overview

In most modern projects, this compilation step happens automatically as part of a build tool (bundlers like Vite or Webpack, or directly via tsc), so developers write and run TypeScript day-to-day without manually invoking the compiler each time.

Final Takeaway

TypeScript isn’t a different language bolted onto JavaScript — it’s JavaScript with an optional layer of guarantees about what shape your data actually has, checked before any of it runs. Type annotations describe individual values. Interfaces and type aliases describe entire object shapes like User, Product, and Order. Unions and intersections combine those shapes flexibly — "one of these" or "all of these together." Generics let logic stay reusable without giving up type safety. And tsconfig.json, feeding into the compilation process, is simply how all of these promises get checked once, consistently, before your code ever reaches a browser. None of it changes what JavaScript can do — it changes how confidently you can say it does what you think it does.

Frequently Asked Questions

Do I have to rewrite my whole JavaScript project to use TypeScript?

> No — because TypeScript is a superset of JavaScript, you can adopt it incrementally, converting files one at a time, or even just adding a tsconfig.json with lenient settings to an existing JavaScript codebase and tightening it gradually.

Should I always use interfaces instead of type aliases?

> Not always — for plain object shapes, either works, and it often comes down to team convention. Reach for type aliases specifically when you need a union, an intersection, or something interfaces can’t express directly.

What does the strict option in tsconfig.json actually do?

> It enables a bundle of stricter type-checking rules together — including catching implicit any types and requiring explicit handling of null/undefined — generally recommended for new projects since it catches more real bugs, at the cost of requiring more upfront type precision.

Do generics make code slower at runtime?

> No — generics are purely a compile-time concept. They’re checked and then completely stripped away during compilation, just like all other type annotations; they have zero impact on the actual JavaScript that runs.

Originally published by Mr Madhukar

Read the complete article on Medium with full formatting & reader responses.