Masterclass on TypeScript

If JavaScript works, why was TypeScript created?
Today we'll answer exactly that, and along the way we'll understand how types work, why interfaces and generics exist, and how a TypeScript project runs in a browser.
Why TypeScript Exists
JavaScript works. Millions of apps run on it. So why did TypeScript need to exist at all?
The problem with plain JavaScript in large applications
JavaScript doesn't check what type of data you're working with. You can pass a number where a string was expected, and JavaScript won't stop you, it'll just let your app break somewhere down the line.
function getDiscountedPrice(price, discount) {
return price - discount;
}
getDiscountedPrice(100, "20"); /// no error, but the math silently breaks
That runs fine, no warning, no error, right up until the output is wrong and you're left debugging why a price came out looking strange.
No safety net. Nothing stops you from passing the wrong data type.
Typos fail silently. Misspell a property name on an object, and JavaScript just gives you
undefined, instead of telling you what went wrong.Refactoring is scary. Rename a field on a
Userobject, and you have to manually hunt down every place that used the old name.Large codebases get hard to trust. With hundreds of files and multiple developers, it becomes nearly impossible to know what shape of data a function actually expects.
Runtime errors vs compile-time errors
A runtime error happens while your app is actually running, often in front of a real user. By the time you see it, the damage (a crash, bad data, a broken page) has already happened.
A compile-time error happens before your code ever runs, right in your editor, while you're still typing.
| JavaScript | TypeScript |
|---|---|
| Error found while running | Error found while writing code |
| User sees a broken app | You see a red squiggly line |
| Debugging happens in prod | Debugging happens before you save the file |
TypeScript's whole job is to catch as many bugs as possible at compile-time, before they ever get the chance to become a runtime error.
Benefits of static typing
Your editor knows exactly what properties and methods are available on a value, and can autocomplete them for you
Mistakes get caught immediately, not three weeks later in production
Function signatures become documentation, you can tell what a function expects just by looking at it
Refactoring becomes safe, since TypeScript will point out every place that breaks
How TypeScript improves developer productivity
It might feel like writing types is "extra work" at first, but it pays off fast. Less time spent debugging weird undefined errors, less time spent guessing what shape of data a function needs, and way more confidence when changing existing code, especially in a codebase you didn't write yourself.
TypeScript as a superset of JavaScript
TypeScript is not a different language. Every valid JavaScript file is already valid TypeScript. TypeScript just adds an optional layer of types on top.
TypeScript
┌─────────────────────────┐
│ │
│ JavaScript │
│ (all valid JS code) │
│ │
│ + Types │
│ + Interfaces │
│ + Generics │
│ │
└─────────────────────────┘
That's why TypeScript is called a superset of JavaScript. It takes everything JavaScript already does, and builds a safety layer on top.
Understanding Type Annotations
A type annotation is just you telling TypeScript, "this value is going to be this kind of data."
Adding types to variables
let username: string = "sarah";
let age: number = 28;
let isActive: boolean = true;
Now, if you try to assign the wrong type of value later, TypeScript stops you immediately.
username = 42; // Error: Type 'number' is not assignable to type 'string'
Function parameter types
This is where type annotations really start to matter, since functions are where most bugs like to hide.
function getDiscountedPrice(price: number, discount: number): number {
return price - discount;
}
getDiscountedPrice(100, "20"); // Error: Argument of type 'string' is not assignable to type 'number'
Now that mistake from earlier gets caught immediately, instead of quietly producing a wrong price.
Function return types
You can also tell TypeScript what a function is supposed to return. This catches cases where a function accidentally returns the wrong thing.
function getUserName(user: { name: string }): string {
return user.name;
}
If someone later changes this function to accidentally return a number, TypeScript will flag it right away.
Type inference
You don't actually have to annotate everything. TypeScript is smart enough to figure out the type on its own, just from the value you assign.
let price = 100; // TypeScript infers this is a number, no annotation needed
TypeScript still fully protects this variable, price = "100" would still throw an error, even though you never wrote : number yourself.
Explicit vs inferred types
| Explicit types | Inferred types | |
|---|---|---|
| You write | let age: number = 28 |
let age = 28 |
| TypeScript figures it out | No, you told it | Yes, from the value |
| Useful when | Function parameters, empty variables, complex objects | Simple variables with an obvious starting value |
A good rule of thumb: let TypeScript infer types when it easily can, and write explicit types when it can't, especially for function parameters, since TypeScript has no value to infer from there.
Interfaces vs Type Aliases
Once you're passing around real objects, like a User or a Product, you need a way to describe their shape. That's where interfaces and type aliases come in.
What interfaces are
An interface describes the shape of an object, what properties it has, and what type each property is.
interface User {
id: number;
name: string;
email: string;
}
const user: User = {
id: 1,
name: "Sarah",
email: "sarah@example.com",
};
If user is missing a property, or has the wrong type for one, TypeScript will catch it.
What type aliases are
A type alias does something very similar, it gives a name to a type, but it's written with the type keyword instead.
type Product = {
id: number;
name: string;
price: number;
};
const product: Product = {
id: 101,
name: "Wireless Mouse",
price: 25,
};
Similarities between them
Both describe the shape of an object
Both can be used to type function parameters, variables, and return values
Both support optional properties, using a
?
interface Order {
id: number;
discount?: number; // optional, might not exist
}
Differences between them
| Interface | Type Alias | |
|---|---|---|
| Can be re-opened and extended later | Yes, interface User { ... } twice merges |
No, TypeScript will error on duplicate names |
| Can describe unions (`string | number`) | No |
| Can extend another type | Yes, using extends |
Yes, using & (intersection) |
| Common use case | Object shapes, especially for classes | Object shapes, unions, and more complex combinations |
When to use interfaces
Use interface when you're describing an object, especially one tied to a class, or one that might need to be extended later by other parts of the codebase, like a shared User type that different features add fields to over time.
When to use type aliases
Use type alias when you need to describe something beyond a plain object, like a union of a few possible values, or when you're combining multiple types together.
Union Types
What union types are
A union type says "this value can be one of a few different types." You write it using the | symbol.
let orderStatus: "pending" | "shipped" | "delivered";
orderStatus = "pending"; // fine
orderStatus = "cancelled"; // Error: not one of the allowed values
Combining multiple possible types
Unions aren't just for strings, they work with any types.
function printId(id: number | string) {
console.log(id);
}
printId(101); // fine
printId("ORD-101"); // also fine
Real-world use cases
Think about an Order in an e-commerce app. Its status is naturally one of a small, known set of values, this is exactly what union types are built for.
interface Order {
id: number;
status: "pending" | "shipped" | "delivered" | "cancelled";
}
Now, if anyone tries to set status to something outside that list, like a typo such as "deliverd", TypeScript catches it instantly.
Handling unions safely
When a value could be more than one type, you often need to check which one it actually is before using it.
function formatId(id: number | string) {
if (typeof id === "number") {
return `#${id}`;
}
return id.toUpperCase();
}
This is called narrowing, checking the type first, so TypeScript knows exactly which version of the value you're working with at each point in the function.
Union Type Visualization
id: number | string
│
├── could be a number ──▶ "#101"
│
└── could be a string ──▶ "ORD-101".toUpperCase()
Intersection Types
What intersection types are
An intersection type combines multiple types into one, using the & symbol. Instead of "one of these," it means "all of these, at once."
type Timestamped = {
createdAt: Date;
};
type Product = {
id: number;
name: string;
};
type ProductWithTimestamp = Product & Timestamped;
Combining multiple type definitions
A value typed as ProductWithTimestamp must now satisfy both Product and Timestamped at the same time.
const product: ProductWithTimestamp = {
id: 101,
name: "Wireless Mouse",
createdAt: new Date(),
};
Miss any one of those fields, and TypeScript will flag it.
Creating reusable type structures
This is really useful for shared building blocks. Instead of repeating createdAt and updatedAt on every single type in your app, you write it once, and combine it in wherever it's needed.
type WithTimestamps = {
createdAt: Date;
updatedAt: Date;
};
type User = { id: number; name: string } & WithTimestamps;
type Order = { id: number; total: number } & WithTimestamps;
Practical examples
Think of an Order that needs both order details and customer details, combined into a single object for a receipt page:
type OrderDetails = { id: number; total: number };
type CustomerDetails = { name: string; email: string };
type Receipt = OrderDetails & CustomerDetails;
Intersection Type Visualization
OrderDetails { id, total }
│
│ &
▼
CustomerDetails { name, email }
│
▼
Receipt { id, total, name, email } ← must have everything, from both
Generic Functions
Why generics are needed
Say you write a function that returns the first item in an array.
function getFirstItem(items: number[]) {
return items[0];
}
This only works for arrays of numbers. If you need the same logic for an array of User objects, or an array of Product objects, you'd have to write the same function over and over, once per type. That's exactly the problem generics solve.
Reusable type-safe functions
A generic function works with whatever type you give it, while still staying fully type-safe.
function getFirstItem<T>(items: T[]): T {
return items[0];
}
getFirstItem<number>([1, 2, 3]); // returns a number
getFirstItem<string>(["a", "b", "c"]); // returns a string
You usually don't even need to specify <T> yourself, TypeScript can infer it from what you pass in.
getFirstItem([1, 2, 3]); // TypeScript infers T is number
Generic parameters
That T is called a generic parameter, it's just a placeholder for "whatever type gets passed in when this function is actually used." You can name it anything, T is just the common convention, short for "Type."
function wrapInArray<T>(value: T): T[] {
return [value];
}
wrapInArray<Product>({ id: 101, name: "Wireless Mouse", price: 25 });
Generic constraints
Sometimes you want to say "this can be any type, but it has to at least have this property." That's what a generic constraint does, using extends.
function printName<T extends { name: string }>(item: T): void {
console.log(item.name);
}
printName({ name: "Sarah", age: 28 }); // fine, has a name
printName({ id: 101 }); // Error: missing 'name'
Real-world examples
Here's a very common one, a generic function that fetches data and types the result based on whatever you tell it to expect:
async function fetchData<T>(url: string): Promise<T> {
const response = await fetch(url);
return response.json();
}
const user = await fetchData<User>("/api/user/1");
const products = await fetchData<Product[]>("/api/products");
Same function, reused for completely different shapes of data, and TypeScript still knows exactly what comes back each time.
Generic Function Type Flow
fetchData<User>("/api/user/1") fetchData<Product[]>("/api/products")
│ │
▼ ▼
T = User T = Product[]
│ │
▼ ▼
Promise<User> Promise<Product[]>
Understanding tsconfig.json
What tsconfig.json is
tsconfig.json is a single file that tells the TypeScript compiler how to treat your entire project, which files to include, how strict to be, what JavaScript version to output, and more.
your-project/
├── tsconfig.json
├── src/
│ ├── index.ts
│ └── user.ts
Why TypeScript projects need it
Without it, you'd have to pass a long list of options to the compiler by hand, every single time you run it. tsconfig.json lets you set all of that up once, and every file in your project automatically follows the same rules.
Common compiler options
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"strict": true,
"outDir": "./dist",
"rootDir": "./src"
}
}
Strict mode
"strict": true turns on a whole bundle of extra safety checks at once, things like requiring every variable to have a known type, and disallowing accidentally using a value that might be null or undefined without checking it first. Almost every real-world TypeScript project keeps this on, it catches a huge number of bugs that would otherwise slip through.
Target configuration
"target" tells TypeScript which version of JavaScript to output. If you set it to "ES2020", TypeScript will use modern JavaScript features in its output. If you set it to something older, like "ES5", TypeScript will rewrite newer syntax into older, more widely-supported code, so it runs on older browsers too.
Module configuration
"module" controls how TypeScript handles import and export statements in the compiled output, since different environments (a browser, Node.js, a bundler) expect these to be written slightly differently.
Project-wide settings
Beyond individual options, tsconfig.json also controls which files are even part of your project:
{
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
This tells TypeScript, "only check and compile files inside src, and ignore everything in node_modules and dist."
tsconfig.json Controlling Compilation
tsconfig.json
│
┌──────────┼──────────┐
▼ ▼ ▼
target strict include/exclude
│ │ │
▼ ▼ ▼
Output JS Type safety Which files
version checks get compiled
TypeScript Compilation Process
How TypeScript becomes JavaScript
TypeScript code doesn't run directly, it gets compiled into plain JavaScript first, using a tool called tsc (the TypeScript compiler).
// user.ts
function greet(name: string): string {
return `Hello, ${name}`;
}
// user.js (after compilation)
function greet(name) {
return `Hello, ${name}`;
}
Notice the types are gone in the output. That's because types only exist to help you while you're writing and checking your code, they don't do anything at runtime, so the compiler strips them out.
What happens during compilation
TypeScript reads your
.tsfilesIt checks every type against how values are actually used, and reports any mismatches
If there are no type errors (or you've chosen to ignore them), it strips out all the type annotations
It outputs plain
.jsfiles, following whatever rules you set intsconfig.json
Why browsers cannot run TypeScript directly
Browsers only understand JavaScript. They have no idea what : string or interface User means, that syntax doesn't exist in JavaScript at all. So TypeScript always has to be converted into plain JavaScript before it can actually run anywhere, in a browser, or in Node.js.
Build workflow overview
TypeScript Compilation Pipeline
Your Code (.ts files)
│
▼
TypeScript Compiler (tsc)
│
reads tsconfig.json for rules
│
checks types, reports errors
│
strips out all type annotations
│
▼
Plain JavaScript (.js files)
│
▼
Runs in the browser or Node.js
This is why TypeScript is best thought of as a tool that sits around your development process, not a replacement for JavaScript. You write TypeScript to catch mistakes early, and by the time your code actually runs anywhere, it's just JavaScript again.
Quick Recap
TypeScript catches mistakes at compile-time, before your code ever runs, instead of letting them surface as runtime errors.
It's a superset of JavaScript, every JS file is already valid TypeScript.
Type annotations can be written explicitly, or left for TypeScript to infer on its own.
Interfaces and type aliases both describe the shape of an object, interfaces can be extended later, type aliases can describe unions and combinations.
Union types (
|) mean "one of these." Intersection types (&) mean "all of these, combined."Generics let you write one function that works safely across many different types, instead of duplicating logic per type.
tsconfig.json controls how your whole project compiles, strictness, target JavaScript version, module format, and which files are included.
TypeScript code always gets compiled down to plain JavaScript before it runs, since browsers and Node.js only understand JavaScript, not TypeScript syntax.
TypeScript isn't a new language, it's JavaScript with a safety net built in, that catches your mistakes while you're writing the code, instead of your users finding them for you.






