TypeScript for Beginners: A Complete Guide

TypeScript for Beginners: A Complete Guide

How Many Runtime Errors Could You Have Caught Before Deploying?

Seriously. Think back to the last time production broke at 2 AM because some variable was undefined where it shouldn’t have been. Or that Friday afternoon when "undefined is not a function" showed up in your error logs and you spent three hours hunting it. You probably remember. Most JavaScript developers do.

TypeScript would’ve caught that before you saved the file.

Not after running tests. Not in staging. Right there in your editor, the moment you wrote the bad line. Red squiggly underline. Hover for the explanation. Fix it. Move on. That’s the pitch, and it’s not hype — it actually works that way in practice, maybe 80-90% of the time. Some edge cases slip through, sure. But the baseline improvement over raw JavaScript is enormous.

I’m going to walk you through TypeScript from zero. Setup, basic types, interfaces, generics, utility types, real-world patterns — the whole path. Every code example here runs. You won’t find vague hand-waving about “type safety good, JavaScript bad.” You’ll get concrete syntax, concrete patterns, and the blunt truth about when TypeScript helps and when it gets in your way.

Let’s get into it.

Install TypeScript in Under 60 Seconds

Two commands. That’s all you need.

npm install -g typescript
tsc --init

First line installs the TypeScript compiler globally. Second one generates a tsconfig.json in your current directory. Done. You’ve got a working TypeScript setup.

Now, the generated tsconfig.json dumps about 80 options on you, most of them commented out. Ignore that. Here’s what a modern project config actually looks like in mid-2026:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"]
}
Tip: Always keep "strict": true on from day one. Some guides tell you to turn it off while learning. Bad advice. Starting strict means you learn the right patterns immediately instead of building habits you’ll have to unlearn later.

A few things worth knowing about these settings. target controls which JavaScript version the compiler outputs — ES2022 covers all modern browsers and Node 18+. moduleResolution: "bundler" is the correct choice if you’re using Vite, webpack, or any modern bundler (it replaced node resolution for most use cases around 2023). And declaration: true generates .d.ts files, which matters when you’re building libraries other people consume.

Primitive Types, Arrays, and Enums — The Foundation

TypeScript’s type system starts with annotations. You stick a colon and a type after your variable name, parameter, or return value. Simple as that.

// Primitive types
let username: string = "anurag";
let age: number = 28;
let isActive: boolean = true;
let nothing: null = null;
let notDefined: undefined = undefined;

// Arrays
let scores: number[] = [95, 87, 92];
let names: Array<string> = ["Alice", "Bob", "Charlie"];

// Tuple -- fixed-length array with specific types per position
let userRecord: [string, number, boolean] = ["anurag", 28, true];

// Enum
enum Status {
  Pending = "PENDING",
  Active = "ACTIVE",
  Suspended = "SUSPENDED",
}

let accountStatus: Status = Status.Active;

Couple of things people get confused by early on.

Tuples vs. arrays. An array like number[] can hold any number of numbers. A tuple like [string, number, boolean] locks both the length and the type at each position. Try pushing a fourth element — the compiler yells at you. It’s useful for things like coordinate pairs, database rows, or function return values where you know the exact shape.

String enums vs. numeric enums. I’d recommend string enums almost always. Numeric enums auto-increment and can bite you during refactoring — reorder members and suddenly your stored values map to the wrong things. String enums are explicit. Status.Pending always equals "PENDING" regardless of declaration order.

Tip: TypeScript can infer types from initial values. Writing let age = 28 gives you the number type automatically. You don’t need to annotate everything. Annotate function parameters and return types. Let inference handle the rest.

Functions: Where Types Start Paying Off

Function signatures are where static typing earns its keep. Wrong argument type? Caught. Missing parameter? Caught. Return type mismatch? Caught. All before you run anything.

function calculateTotal(price: number, quantity: number, tax: number = 0.08): number {
  return price * quantity * (1 + tax);
}

// Arrow function with type annotation
const greet = (name: string): string => `Hello, ${name}!`;

// Function that returns nothing
function logMessage(message: string): void {
  console.log(`[LOG]: ${message}`);
}

// Optional parameters use ?
function createUser(name: string, email: string, role?: string): object {
  return { name, email, role: role ?? "viewer" };
}

Notice the ? on role. Optional parameters. The caller can skip it, and inside the function it’s string | undefined. The ?? operator handles the default — if role is undefined or null, it falls back to "viewer".

Also notice void. Not undefined, not null. void means “this function doesn’t return anything meaningful.” The compiler won’t let you assign its result to a variable. Well, technically you can assign it, but you’d get a void type, which is useless.

Why annotate return types if TypeScript can infer them? Because inference tells you what the function does return. Annotation tells you what it should return. Subtle difference, big deal. If you refactor a function and accidentally change its return type, inference silently goes along with it. An annotation catches the mistake immediately. I’d say annotate return types on any function longer than three or four lines.

Interfaces — Describing the Shape of Things

Primitives and functions are one thing. Real applications deal with objects — user records, API responses, config blobs, shopping carts. Interfaces describe what an object looks like.

interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;           // optional property
  readonly createdAt: Date;  // cannot be modified after creation
}

function displayUser(user: User): string {
  return `${user.name} (${user.email})`;
}

const newUser: User = {
  id: 1,
  name: "Anurag",
  email: "anurag@example.com",
  createdAt: new Date(),
};

Pass an object missing id? Compiler error. Sneak in an extra property the interface doesn’t know about? Error again (in most cases). Try to reassign createdAt? Blocked — it’s readonly.

Interfaces compose. You build small ones, then extend them into bigger ones.

interface BaseEntity {
  id: number;
  createdAt: Date;
  updatedAt: Date;
}

interface Post extends BaseEntity {
  title: string;
  content: string;
  published: boolean;
  author: User;
}

interface Comment extends BaseEntity {
  body: string;
  postId: number;
  author: User;
}

Both Post and Comment inherit id, createdAt, and updatedAt from BaseEntity. Change the base? Every child interface picks up the change. No copy-pasting, no forgetting to update one of five places.

Interfaces vs. Type Aliases — When to Use Which

  • Interfaces work best for object shapes, especially when you expect to extend them later. They support declaration merging (two interfaces with the same name auto-combine), which libraries use heavily.
  • Type aliases handle everything interfaces can’t: unions, intersections, primitives, mapped types. Use them for “either/or” types and computed types.
  • For object shapes in application code? Flip a coin. Both work. Pick one convention and stick with it across your codebase.

Type Aliases, Unions, and Intersections

Sometimes you need a type that says “it’s either this or that.” Interfaces can’t do that. Type aliases can.

// Union types
type Priority = "low" | "medium" | "high" | "critical";
type Result = string | number;
type ID = string | number;

// Intersection types -- combine multiple types
type AdminUser = User & {
  permissions: string[];
  isSuperAdmin: boolean;
};

// Mapped types from union
type StatusMap = {
  [key in Priority]: number;
};

const taskCounts: StatusMap = {
  low: 12,
  medium: 8,
  high: 3,
  critical: 1,
};

Unions are everywhere in real codebases. API endpoints that return different shapes depending on success or failure. Form fields that accept either a string or a number. Event types that could be click, keypress, or scroll. Without unions, you’d be reaching for any — and any turns off the type checker entirely. Defeats the whole point.

Intersections go the other direction. Instead of “this or that,” they mean “this and that.” AdminUser above has every property of User plus permissions and isSuperAdmin. You’re merging shapes together, sort of like interface inheritance but more flexible since it works with type aliases.

Mapped types like StatusMap deserve a second look. That [key in Priority] syntax creates a property for each member of the Priority union. Forget one? The compiler complains. Add a new priority level later? Every StatusMap object in your codebase lights up with errors until you add the new key. That’s the kind of safety net that prevents bugs from surviving a refactor.

Generics: One Function, Many Types

Here’s where things get powerful and — if I’m honest — where a lot of beginners hit a wall.

Generics let you write a function that works with any type without giving up type information. You’re basically telling the compiler, “I don’t know the type yet, but once someone calls this function with a specific type, enforce that type all the way through.”

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

const firstNumber = getFirst([10, 20, 30]);    // type: number | undefined
const firstString = getFirst(["a", "b", "c"]); // type: string | undefined

// Generic with constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Anurag", age: 28, email: "a@b.com" };
const userName = getProperty(user, "name"); // type: string
// getProperty(user, "phone"); // Error: "phone" is not a key of user

That <T> is a type parameter. Think of it like a function parameter, but for types. When you call getFirst([10, 20, 30]), TypeScript infers T = number and flows that through — the return type becomes number | undefined. You didn’t have to write a separate function for numbers, one for strings, one for objects. One function handles all of them.

The constrained generic — K extends keyof T — restricts what K can be. It’s not any string. It must be a real key of the object you pass in. Try "phone" when the object only has name, age, and email? Rejected at compile time. No more undefined accidents at runtime.

Where generics really shine is in reusable data structures. API wrappers, for instance:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: Date;
}

interface PaginatedResponse<T> extends ApiResponse<T[]> {
  page: number;
  totalPages: number;
  totalItems: number;
}

// Usage
type UserResponse = ApiResponse<User>;
type UserListResponse = PaginatedResponse<User>;

async function fetchUsers(): Promise<PaginatedResponse<User>> {
  const response = await fetch("/api/users");
  return response.json();
}
Tip: Naming convention for type parameters: T for “type,” K for “key,” V for “value,” E for “element.” When you’ve got more than two, consider using descriptive names like TInput and TOutput. Single letters get confusing fast in complex generics.

Don’t overcomplicate generics early on. If you’re writing a function that only works with one type, just use that type directly. Generics are for when you catch yourself writing the same function three times with different type annotations. That’s the signal to reach for <T>.

Utility Types: TypeScript’s Built-In Transformers

Rewriting types by hand every time you need a slight variation? Waste of time. TypeScript ships with utility types that transform existing types on the fly.

interface Task {
  id: number;
  title: string;
  description: string;
  completed: boolean;
  assignee: User;
}

// Partial -- all properties become optional
type TaskUpdate = Partial<Task>;

// Pick -- select specific properties
type TaskSummary = Pick<Task, "id" | "title" | "completed">;

// Omit -- exclude specific properties
type NewTask = Omit<Task, "id">;

// Required -- all properties become required
type StrictTask = Required<Task>;

// Record -- create an object type with specific key and value types
type TasksByStatus = Record<string, Task[]>;

// Real-world usage
function updateTask(id: number, updates: Partial<Omit<Task, "id">>): Task {
  // fetch existing task, merge updates, return updated task
  return { id, title: "", description: "", completed: false, assignee: {} as User, ...updates };
}

That last one — Partial<Omit<Task, "id">> — chains two utility types together. First Omit removes id (you shouldn’t be updating a task’s ID). Then Partial makes everything left optional (an update might change only the title, or only the assignee, or three fields at once).

You’ll use Partial constantly. PATCH endpoints, form state updates, configuration objects with defaults — anywhere something can be “partially filled in.” Pick and Omit show up in API responses where the frontend only needs a subset of the full object. Record appears in lookup tables and maps.

Quick Reference: Common Utility Types

  • Partial<T> — All properties optional
  • Required<T> — All properties required
  • Pick<T, Keys> — Keep only specified properties
  • Omit<T, Keys> — Remove specified properties
  • Record<Keys, Value> — Object type from key-value mapping
  • Readonly<T> — All properties become readonly
  • ReturnType<F> — Extract a function’s return type
  • Parameters<F> — Extract a function’s parameter types as a tuple

Type Narrowing and Type Guards

When you’ve got a union type, TypeScript needs help figuring out which branch you’re in. That’s narrowing.

interface Dog {
  kind: "dog";
  bark(): void;
}

interface Cat {
  kind: "cat";
  meow(): void;
}

type Pet = Dog | Cat;

function interact(pet: Pet): void {
  switch (pet.kind) {
    case "dog":
      pet.bark(); // TypeScript knows this is Dog
      break;
    case "cat":
      pet.meow(); // TypeScript knows this is Cat
      break;
  }
}

// Type guard function
function isString(value: unknown): value is string {
  return typeof value === "string";
}

function processInput(input: unknown): string {
  if (isString(input)) {
    return input.toUpperCase(); // TypeScript knows input is string here
  }
  return String(input);
}

See that kind property? It’s called a discriminated union. Every member of the union has the same property name (kind) but a different literal value ("dog" vs "cat"). When you switch on it, TypeScript narrows the type inside each case branch automatically. No casting, no as assertions, no guesswork.

Custom type guards — functions returning value is SomeType — handle cases where typeof and instanceof aren’t enough. Maybe you’re checking whether an API response has a specific shape. Maybe you need to validate form data against a complex interface. Write a guard function, and TypeScript treats the return value as proof that the type is what you say it is.

Tip: Prefer unknown over any for values you haven’t validated yet. any disables type checking entirely — the compiler gives up. unknown forces you to narrow before using the value, which means you can’t accidentally call .toUpperCase() on something that might be a number. Slightly more work. Much safer.

Discriminated unions show up constantly in state management. Think about a network request: it’s either loading, succeeded, or failed. Three distinct states, each with different available data. Model that as a discriminated union and TypeScript won’t let you access response.data when the state is "loading" — because in the loading state, there’s no data yet. Bugs that would’ve taken 30 minutes to debug in plain JavaScript just… don’t compile.

Real-World Patterns You’ll Actually Use

Theory’s over. Here’s what TypeScript looks like in production code — the patterns that come up every week if you’re building web apps.

// Typed event handler
function handleSubmit(event: React.FormEvent<HTMLFormElement>): void {
  event.preventDefault();
  const formData = new FormData(event.currentTarget);
  const email = formData.get("email") as string;
}

// Type-safe fetch wrapper
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
  const response = await fetch(url, {
    headers: { "Content-Type": "application/json" },
    ...options,
  });

  if (!response.ok) {
    throw new Error(`API error: ${response.status} ${response.statusText}`);
  }

  return response.json() as Promise<T>;
}

// Usage
const users = await apiFetch<User[]>("/api/users");
const post = await apiFetch<Post>("/api/posts/1");

That apiFetch wrapper is probably in some form in every TypeScript codebase I’ve worked on. You write it once, and now every API call in your app gets type checking on the response. users is typed as User[]. Try accessing users[0].phone when User doesn’t have a phone field? Red squiggly. Caught before it ever runs.

The React event handler typing is worth memorizing if you work with React. React.FormEvent<HTMLFormElement> gives you event.currentTarget typed as an HTMLFormElement, which means new FormData(event.currentTarget) just works — no casting required on the form element itself. The as string on formData.get() is a small concession: FormData.get() returns FormDataEntryValue | null, and you know it’s a string because it’s a text input. Fair trade-off.

Migrating from JavaScript: The Practical Path

Don’t rewrite your entire codebase. Please. I’ve seen teams try that approach and stall for months.

Here’s what actually works:

  1. Rename one file from .js to .ts. Pick something small — a utility module, a helper function, a config file.
  2. Fix the errors the compiler shows you. Most will be “parameter implicitly has ‘any’ type.” Add the annotations.
  3. Move to the next file. Repeat.
  4. Save the complex stuff for last. Files with heavy dynamic typing, third-party library interactions, or weird runtime tricks — those can wait. Get the easy wins first.

TypeScript supports incremental adoption by design. Your tsconfig.json can include both .ts and .js files. The allowJs option lets the compiler process JavaScript files alongside TypeScript ones. You can even add type checking to plain JavaScript files using JSDoc comments — no renaming required. Not ideal long-term, but it’s a stepping stone.

Tip: Your tsconfig.json has a "strict" flag that enables several individual checks: strictNullChecks, noImplicitAny, strictFunctionTypes, and more. If strict mode produces too many errors during migration, turn on individual checks one at a time. Start with noImplicitAny — it catches the most bugs with the least friction.

Common Mistakes and How to Dodge Them

Every TypeScript beginner makes these. Don’t feel bad about it. Just know they’re coming so you can recognize them faster.

Overusing any. When a type gets complicated and you can’t figure it out, slapping any on it feels like a relief. Resist. Every any is a hole in your safety net. Use unknown and narrow, or take the time to write the correct type. Future you will be grateful.

Fighting the compiler instead of listening to it. If TypeScript says your code has a problem, it’s almost always right. The instinct to add as any or @ts-ignore is strong, especially under deadline pressure. Nine times out of ten, the compiler is pointing at a genuine bug or a misunderstanding in your types.

Making interfaces too specific too early. You build a User interface with 20 properties because the database has 20 columns. Then every component that touches a user needs all 20 properties even if it only uses three. Design small interfaces and compose them with extends, Pick, or Omit. You’ll thank yourself during testing when you don’t have to mock 17 irrelevant fields.

Ignoring strictNullChecks. Without it, null and undefined are assignable to every type. Meaning your string variable could secretly be null, and TypeScript won’t warn you. Keep this on. Handle the null cases explicitly. That’s where half of JavaScript’s runtime errors come from.

What’s Coming: TypeScript’s Next Moves

TypeScript isn’t standing still, and the next year or two look interesting.

The team has been working on isolatedDeclarations mode, which landed in TypeScript 5.5 and keeps getting refined. Goal: let tools other than tsc generate declaration files without running the full type checker. Sounds boring. Impact is huge — it opens the door to massively parallel builds and faster CI pipelines. Projects like SWC and oxc are already taking advantage of it.

There’s also the ongoing push toward faster compiler performance. The TypeScript team started porting the compiler to Go in early 2025 — a project they’re calling “tsgo” internally. Early benchmarks showed 10x speedup on large codebases. If that ships (and it’s looking likely), the “TypeScript is slow” complaint mostly disappears. Cold builds that took 45 seconds could finish in under 5.

On the language side, pattern matching proposals keep circulating in TC39 discussions. If JavaScript gets native pattern matching, TypeScript will support it immediately — and discriminated unions will become even more natural to work with. We might also see deeper integration with the ECMAScript decorator standard, which TypeScript 5.0 adopted but the ecosystem hasn’t fully absorbed yet.

Tooling is moving fast too. Editors are getting smarter about TypeScript-specific refactoring. AI coding assistants work dramatically better with typed code (the type annotations give them context that plain JavaScript lacks). And framework-level type inference keeps improving — check how Nuxt, tRPC, and Hono handle end-to-end type safety today versus even two years ago. Night and day.

Point is: learning TypeScript now isn’t just about catching today’s bugs. It’s positioning yourself on a trajectory that the entire JavaScript ecosystem is already moving toward. The gap between “JavaScript developer” and “TypeScript developer” gets wider every quarter. Jump in now while the learning curve is just a curve and not a cliff.

Leave a Comment

Your email address will not be published. Required fields are marked with an asterisk.