Master TypeScript from scratch. Learn how the compiler works, set up projects, explore type systems, and build production-ready Node.js applications with confidence.

JavaScript powers billions of lines of code across the web. Yet it's dynamically typed, which means errors often surface at runtime—sometimes in production. TypeScript solves this by adding static typing to JavaScript, catching bugs during development instead of after deployment.
If you've written JavaScript and wondered why your IDE couldn't catch that typo in a property name, or why a function suddenly received undefined when you expected a string, TypeScript is your answer.
This guide walks you through everything: how TypeScript works under the hood, setting up your first project, understanding the type system, and building real applications. Whether you're a JavaScript developer looking to level up or new to programming, you'll find practical, actionable knowledge here.
TypeScript is a superset of JavaScript that adds static typing. Think of it as JavaScript with guardrails—you declare what types your variables, functions, and objects should have, and the TypeScript compiler checks your code before it runs.
Here's the key insight: TypeScript doesn't run in browsers or Node.js directly. It compiles to plain JavaScript first. Your .ts files become .js files, which then execute normally.
JavaScript's flexibility is both a strength and a weakness. You can write:
function add(a, b) {
return a + b;
}
add(5, 10); // 15
add("5", "10"); // "510" (string concatenation, not addition)
add(5, "10"); // "510" (type coercion)The function works, but the behavior is unpredictable. TypeScript prevents this:
function add(a: number, b: number): number {
return a + b;
}
add(5, 10); // ✓ 15
add("5", "10"); // ✗ Error: Argument of type 'string' is not assignable to parameter of type 'number'Understanding the compilation pipeline is crucial. Here's what happens:
TypeScript Source Code (.ts)
↓
TypeScript Compiler (tsc)
↓
Type Checking & Validation
↓
JavaScript Output (.js)
↓
JavaScript Runtime (Node.js / Browser)Step 1: Write TypeScript
function greet(name: string): string {
return `Hello, ${name}!`;
}
const message = greet("Alice");
console.log(message);Step 2: Compile with tsc
tsc hello.tsStep 3: Generated JavaScript
function greet(name) {
return `Hello, ${name}!`;
}
const message = greet("Alice");
console.log(message);Notice: All type annotations are stripped. The output is pure JavaScript.
Step 4: Execute
node hello.js
# Output: Hello, Alice!The type information exists only during development. Once compiled, it's gone—the runtime doesn't know or care about types.
Let's build a real Node.js project with TypeScript.
mkdir my-typescript-app
cd my-typescript-app
npm init -ynpm install --save-dev typescriptnpx tsc --initThis creates a tsconfig.json file with sensible defaults. We'll customize it next.
Open tsconfig.json and update it for Node.js development:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Key options explained:
target: JavaScript version to compile to (ES2020 is modern and widely supported)module: Module system (commonjs for Node.js)outDir: Where compiled .js files gorootDir: Where your .ts source files livestrict: Enable all strict type checking optionssourceMap: Generate .map files for debuggingmkdir srcfunction sayHello(name: string): string {
return `Hello, ${name}!`;
}
const greeting = sayHello("World");
console.log(greeting);npx tscCheck the dist folder—you'll see hello.js:
node dist/hello.js
# Output: Hello, World!Congratulations. You've written, compiled, and executed TypeScript.
Types are the heart of TypeScript. Let's explore the core types you'll use daily.
// String
const name: string = "Alice";
// Number (includes integers and floats)
const age: number = 30;
const pi: number = 3.14;
// Boolean
const isActive: boolean = true;
// Undefined and Null
const nothing: undefined = undefined;
const empty: null = null;
// Symbol (rarely used)
const id: symbol = Symbol("unique-id");
// BigInt (for very large numbers)
const bigNumber: bigint = 9007199254740991n;// Array of strings
const colors: string[] = ["red", "green", "blue"];
// Alternative syntax
const numbers: Array<number> = [1, 2, 3];
// Array of mixed types (union type)
const mixed: (string | number)[] = ["hello", 42, "world"];
// Array of objects
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
];Interfaces define the shape of objects:
interface Product {
id: number;
name: string;
price: number;
inStock?: boolean; // Optional property
}
const laptop: Product = {
id: 1,
name: "MacBook Pro",
price: 1999
// inStock is optional, so it's fine to omit
};
const phone: Product = {
id: 2,
name: "iPhone 15",
price: 999,
inStock: true
};A variable can be one of several types:
type Status = "pending" | "success" | "error";
function handleStatus(status: Status): void {
if (status === "pending") {
console.log("Loading...");
} else if (status === "success") {
console.log("Done!");
} else {
console.log("Something went wrong");
}
}
handleStatus("success"); // ✓
handleStatus("unknown"); // ✗ ErrorGenerics let you write reusable code that works with any type:
// Generic function
function getFirstElement<T>(arr: T[]): T {
return arr[0];
}
const firstString = getFirstElement(["a", "b", "c"]); // Type: string
const firstNumber = getFirstElement([1, 2, 3]); // Type: number
// Generic interface
interface Container<T> {
value: T;
getValue(): T;
}
const stringContainer: Container<string> = {
value: "hello",
getValue() {
return this.value;
}
};Both define types, but they're slightly different:
// Type alias (can represent any type)
type Point = {
x: number;
y: number;
};
// Interface (specifically for objects)
interface Point {
x: number;
y: number;
}
// Type alias can be a union
type ID = string | number;
// Interface cannot be a union
// interface ID = string | number; // ✗ Syntax errorFor most cases, use interfaces for objects and type aliases for everything else.
Let's build something practical: a simple user management system.
src/
├── types/
│ └── user.ts
├── services/
│ └── userService.ts
└── index.tsexport interface User {
id: number;
name: string;
email: string;
age: number;
isActive: boolean;
}
export type CreateUserInput = Omit<User, "id">;
export type UpdateUserInput = Partial<CreateUserInput>;import { User, CreateUserInput, UpdateUserInput } from "../types/user";
class UserService {
private users: User[] = [];
private nextId: number = 1;
createUser(input: CreateUserInput): User {
const user: User = {
id: this.nextId++,
...input
};
this.users.push(user);
return user;
}
getUser(id: number): User | undefined {
return this.users.find(user => user.id === id);
}
getAllUsers(): User[] {
return this.users;
}
updateUser(id: number, input: UpdateUserInput): User | undefined {
const user = this.getUser(id);
if (!user) return undefined;
Object.assign(user, input);
return user;
}
deleteUser(id: number): boolean {
const index = this.users.findIndex(user => user.id === id);
if (index === -1) return false;
this.users.splice(index, 1);
return true;
}
}
export default new UserService();import userService from "./services/userService";
// Create users
const alice = userService.createUser({
name: "Alice Johnson",
email: "alice@example.com",
age: 28,
isActive: true
});
const bob = userService.createUser({
name: "Bob Smith",
email: "bob@example.com",
age: 35,
isActive: false
});
console.log("Created users:", userService.getAllUsers());
// Update user
userService.updateUser(alice.id, { age: 29 });
console.log("Updated Alice:", userService.getUser(alice.id));
// Delete user
userService.deleteUser(bob.id);
console.log("After deletion:", userService.getAllUsers());npx tsc
node dist/index.jsOutput:
Created users: [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', age: 28, isActive: true },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', age: 35, isActive: false }
]
Updated Alice: { id: 1, name: 'Alice Johnson', email: 'alice@example.com', age: 29, isActive: true }
After deletion: [ { id: 1, name: 'Alice Johnson', email: 'alice@example.com', age: 29, isActive: true } ]Enums define a set of named constants:
enum UserRole {
Admin = "admin",
Moderator = "moderator",
User = "user"
}
interface Account {
id: number;
role: UserRole;
}
const admin: Account = {
id: 1,
role: UserRole.Admin
};
// Numeric enums (default)
enum Status {
Pending = 0,
Active = 1,
Inactive = 2
}TypeScript provides built-in utility types for common transformations:
interface User {
id: number;
name: string;
email: string;
}
// Partial: all properties optional
type PartialUser = Partial<User>;
// Required: all properties required
type RequiredUser = Required<PartialUser>;
// Readonly: all properties readonly
type ReadonlyUser = Readonly<User>;
// Pick: select specific properties
type UserPreview = Pick<User, "id" | "name">;
// Omit: exclude specific properties
type UserWithoutEmail = Omit<User, "email">;
// Record: create object with specific keys
type UserRoles = Record<"admin" | "user" | "guest", User>;Types that depend on other types:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<number>; // false
// Practical example: extract array element type
type ArrayElement<T> = T extends (infer E)[] ? E : T;
type StringArray = ArrayElement<string[]>; // string
type SingleNumber = ArrayElement<42>; // 42anyfunction processData(data: any): any {
return data.toUpperCase();
}
// No type checking—defeats the purpose of TypeScriptfunction processData(data: string): string {
return data.toUpperCase();
}
// Type-safe and clearinterface User {
profile?: {
avatar?: string;
};
}
const user: User = {};
const avatar = user.profile.avatar; // ✗ Error: Object is possibly 'undefined'const avatar = user.profile?.avatar; // ✓ Safe, returns undefined if profile doesn't existfunction printId(id: string | number): void {
console.log(id.toUpperCase()); // ✗ Error: number doesn't have toUpperCase
}function printId(id: string | number): void {
if (typeof id === "string") {
console.log(id.toUpperCase());
} else {
console.log(id);
}
}TypeScript's strict mode catches real bugs. Don't disable it:
{
"compilerOptions": {
"strict": false,
"noImplicitAny": false
}
}{
"compilerOptions": {
"strict": true
}
}Always enable strict: true in tsconfig.json. It catches more errors during development.
src/
├── features/
│ ├── users/
│ │ ├── types.ts
│ │ ├── service.ts
│ │ └── controller.ts
│ ├── products/
│ │ ├── types.ts
│ │ ├── service.ts
│ │ └── controller.ts// Good: Clear interface for external consumers
export interface UserRepository {
findById(id: number): Promise<User | null>;
save(user: User): Promise<void>;
}
// Implementation detail
class PostgresUserRepository implements UserRepository {
async findById(id: number): Promise<User | null> {
// Implementation
}
async save(user: User): Promise<void> {
// Implementation
}
}TypeScript is smart about inferring types. Don't over-annotate:
const name: string = "Alice";
const age: number = 30;
const isActive: boolean = true;const name = "Alice"; // inferred as string
const age = 30; // inferred as number
const isActive = true; // inferred as booleanEnsure all cases are handled:
type Status = "pending" | "success" | "error";
function handleStatus(status: Status): string {
switch (status) {
case "pending":
return "Loading...";
case "success":
return "Done!";
case "error":
return "Failed!";
default:
const _exhaustive: never = status; // Error if case missing
return _exhaustive;
}
}/**
* Represents a paginated response from the API.
* @template T The type of items in the response
*/
interface PaginatedResponse<T> {
/** Array of items for this page */
items: T[];
/** Total number of items across all pages */
total: number;
/** Current page number (1-indexed) */
page: number;
/** Number of items per page */
pageSize: number;
}TypeScript isn't always the right choice:
For one-off scripts or quick prototypes, TypeScript overhead isn't worth it:
# Just use Node.js directly
node script.jsWhen exploring ideas quickly, JavaScript's flexibility can be faster.
If your team isn't comfortable with TypeScript, the learning curve might slow you down initially.
If your project has few external dependencies, type safety gains are smaller.
Recompile automatically when files change:
npx tsc --watch{
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js",
"dev:run": "tsc && node dist/index.js"
}
}Then run:
npm run dev # Watch mode
npm run build # One-time compile
npm start # Run compiled codeSource maps enable debugging TypeScript directly:
{
"compilerOptions": {
"sourceMap": true,
"outDir": "./dist"
}
}In VS Code, create .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/dist/index.js",
"preLaunchTask": "tsc: build",
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
}
]
}TypeScript transforms how you write JavaScript. By catching errors during development, providing IDE autocomplete, and making code self-documenting, it saves time and prevents bugs.
Key takeaways:
Next steps:
TypeScript isn't just about types—it's about confidence. Write it once, catch bugs early, and ship with peace of mind.