What is TypeScript?
TypeScript is a statically typed superset of JavaScript that adds optional type annotations checked at compile time. It compiles to plain JavaScript, so it runs anywhere JavaScript runs — browsers, Node.js, Deno and Bun.
Created by Microsoft in 2012, TypeScript has become the default choice for serious JavaScript projects. The reason is simple: it catches bugs before your code runs. A typo in a property name, a missing argument, a wrong return type — TypeScript flags these at compile time instead of letting them become production crashes.
The best way to think about TypeScript is as JavaScript with a safety net. You write the same language you already know, but you can describe the shape of your data and the contracts of your functions. The compiler checks that everything fits, and then strips the types away, leaving clean JavaScript.
Why TypeScript matters
JavaScript is dynamically typed, which means you can pass any value to any function and the error only shows up when that code actually runs. In a small script that is fine. In a large application with dozens of developers, it is a recipe for production bugs.
TypeScript solves this by checking types before the code runs:
- Catch bugs at compile time — misspell a property, pass the wrong argument type or forget to handle null, and TypeScript tells you immediately.
- Better editor experience — autocomplete works because the editor knows what properties an object has. Refactoring is safe because the compiler finds every place that needs updating.
- Self-documenting code — function signatures describe exactly what they expect and return. Reading the types tells you how to use the code without reading the implementation.
- Safer refactoring — change a type and the compiler highlights every place that breaks. No more hunting through the codebase for runtime errors.
- Progressive adoption — you can add TypeScript to an existing JavaScript project incrementally, one file at a time.
TypeScript does not add runtime overhead. The types are erased during compilation, so the JavaScript that ships to users is identical whether you wrote it in TypeScript or not.
How TypeScript works
TypeScript code lives in .ts files. The TypeScript compiler (tsc) reads these files, checks the types, and outputs .js files with the type annotations removed.
// app.ts
function add(a: number, b: number): number {
return a + b;
}
const result = add(1, 2); // result is number
The compiler checks that a and b are numbers, that the function returns a number, and that result receives a number. If anything does not match, you get a compile-time error. The output JavaScript is simply:
function add(a, b) {
return a + b;
}
const result = add(1, 2);
No types, no overhead. The safety was in the build step only.
Type annotations
Type annotations describe the type of a variable, parameter or return value:
// primitives
let name: string = "Ada";
let age: number = 36;
let active: boolean = true;
// arrays
let skills: string[] = ["HTML", "CSS"];
let scores: Array<number> = [95, 87, 92];
// function parameters and return
function greet(name: string): string {
return `Hello, ${name}!`;
}
You do not always need annotations. TypeScript infers types from the value:
let count = 0; // inferred as number
const items = ["a"]; // inferred as string[]
Prefer inference when the type is obvious. Annotate when the type is not clear from the value, when you need a more general type, or when the default inference is too narrow.
Type inference
TypeScript automatically deduces types from context:
// from assignment
let x = 5; // number
// from return value
function doubling(n: number) {
return n * 2; // inferred as number
}
// from context
const numbers = [1, 2, 3]; // number[]
const doubled = numbers.map(n => n * 2); // number[]
Inference means you write less code while getting the same safety. Most TypeScript code relies heavily on inference — only annotate when it adds clarity.
Union types
A union type says a value can be one of several types:
type Status = "loading" | "success" | "error";
function handleStatus(status: Status) {
if (status === "loading") {
// TypeScript knows status is "loading" here
}
}
let value: string | number;
value = "hello";
value = 42;
Unions model real-world data well. API responses might succeed or fail. A field might be a string or null. A function might accept different input types.
Type narrowing
TypeScript narrows union types inside conditionals:
function process(value: string | number) {
if (typeof value === "string") {
// TypeScript knows value is string here
return value.toUpperCase();
}
// TypeScript knows value is number here
return value.toFixed(2);
}
Narrowing also works with:
inoperator — check if a property existsinstanceof— check class instances- Equality checks —
===and!== - Discriminated unions — check a common tag property
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Shape = Circle | Square;
function area(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
}
}
Discriminated unions are one of TypeScript’s most powerful patterns. The kind property tells TypeScript exactly which variant you are dealing with.
Interfaces and type aliases
An interface describes the shape of an object — its properties, their types and whether they are required. A type alias gives any type a name, including unions, tuples, primitives and function types.
// Interface
interface User {
id: number;
name: string;
email: string;
}
// Type alias with the same shape
type User = {
id: number;
name: string;
email: string;
};
For plain object shapes the two are interchangeable. The differences appear when you compose or extend types.
Optional and readonly properties
interface User {
id: number;
name: string;
email?: string; // optional — string | undefined
readonly createdAt: Date;
}
const user: User = { id: 1, name: "Ada", createdAt: new Date() };
user.createdAt = new Date(); // Error: cannot assign to a readonly property
Extending interfaces
Interfaces inherit from other interfaces with extends, including several at once:
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface User extends Timestamped {
id: number;
name: string;
}
Type aliases for unions, tuples and functions
Type aliases shine when the type is not a plain object:
type ID = string | number;
type Status = "idle" | "loading" | "success" | "error";
type Pair = [string, number];
type Callback = (data: unknown) => void;
Intersection types
Combine types with &:
type User = { id: number; name: string };
type WithTimestamp = { createdAt: Date };
type TimestampedUser = User & WithTimestamp;
Interface or type?
Use interfaces for object shapes that may be extended or implemented by classes. Use type aliases for unions, intersections, tuples and function types. Pick one convention for object shapes and stay consistent.
Generics
Generics are type parameters: they let a function, interface or class work with any type while preserving type safety. Instead of hardcoding a type or falling back to any, you pass a type variable that is filled in at the call site.
function first<T>(items: T[]): T | undefined {
return items[0];
}
const num = first([1, 2, 3]); // number
const str = first(["a", "b"]); // string
Without generics you would either use any and lose type information, or write a separate function for every type.
Generic functions
A type parameter is declared in angle brackets. TypeScript usually infers it from the arguments, so you rarely pass it explicitly:
function merge<T, U>(a: T, b: U): T & U {
return { ...a, ...b };
}
const result = merge({ name: "Ada" }, { age: 36 });
// { name: string; age: number }
Generic interfaces and classes
interface Response<T> {
data: T;
status: number;
}
interface User {
id: number;
name: string;
}
const response: Response<User> = {
data: { id: 1, name: "Ada" },
status: 200,
};
class Stack<T> {
private items: T[] = [];
push(item: T) { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}
Constraints
Use extends to restrict what a generic can accept:
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
Default type parameters
interface Paginated<T = unknown> {
data: T[];
total: number;
page: number;
}
const response: Paginated = { data: [1, "a"], total: 10, page: 1 };
Generics are erased at runtime, so they add no overhead — they exist only to make the compiler and your editor smarter.
The tsconfig.json
The tsconfig.json file configures the TypeScript compiler:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Key options:
- target — which JavaScript version to emit (ES2020 is a good default)
- strict — enables all strict type-checking flags
- module — the module system for output (ESNext for modern projects)
- outDir — where compiled JavaScript goes
- esModuleInterop — allows default imports from CommonJS modules
Always use "strict": true for new projects. It catches more bugs at the cost of requiring slightly more explicit code.
Installing TypeScript
# As a project dependency (recommended)
npm install --save-dev typescript
# Globally (not recommended for teams)
npm install -g typescript
Compile your project:
# Using npx
npx tsc
# Or add a script to package.json
npm run build
For development with watch mode:
npx tsc --watch
Most modern frameworks (Next.js, Vite, Astro) handle TypeScript compilation for you — you do not need to run tsc manually.
Common mistakes
- Using
anyeverywhere, which disables type checking. - Annotating everything when inference already knows the type.
- Not enabling strict mode in tsconfig.
- Confusing TypeScript types with runtime values — types are erased at compile time.
- Importing types incorrectly — use
import typefor type-only imports. - Ignoring compiler errors instead of fixing them.
- Not installing
@typespackages for JavaScript libraries. - Making every interface property optional, which hides required checks.
- Using
anyinstead of a generic, throwing away the type information you wanted to keep.
Best practices
- Enable strict mode from day one.
- Let inference do the work — only annotate when it adds value.
- Use
unknowninstead ofanywhen you do not know the type. - Prefer interfaces for object shapes that might be extended.
- Use discriminated unions for state machines and variant data.
- Keep type definitions close to where they are used.
- Use
import typefor type-only imports to keep runtime code small. - Treat the compiler as a teammate, not an obstacle.
What to learn next
You now have the TypeScript fundamentals: annotations, inference, unions, narrowing, interfaces, type aliases and generics. From here the natural next step is Advanced Types for utility types, conditional types and mapped types. Pick a project, refactor some code and let the practice compound.