DEV Community

Alex Chen
Alex Chen

Posted on

TypeScript: The Practical Guide for JavaScript Developers (2026)

TypeScript: The Practical Guide for JavaScript Developers (2026)

TypeScript isn't just "JavaScript with types." It's a productivity multiplier that catches bugs before you run code.

Why TypeScript in 2026?

The honest comparison:

JavaScript:
→ Errors found at runtime (in production, on user's browser)
→ IDE autocomplete is guesswork
→ Refactoring is a game of "did I break something?"
→ Documentation lives separately from code

TypeScript:
→ Errors found at compile time (before you even save)
→ Perfect autocomplete (the compiler KNOWS your types)
→ Refactor with confidence (rename propagates everywhere)
→ Types ARE documentation that never goes stale

Trade-off: More upfront typing → way fewer debugging hours later
Enter fullscreen mode Exit fullscreen mode

Setup

# Initialize TypeScript project
npm init -y
npm install --save-dev typescript @types/node tsx
npx tsc --init              # Creates tsconfig.json

# package.json scripts:
{
  "scripts": {
    "build": "tsc",
    "dev": "tsx watch src/index.ts",     # Hot-reload dev server
    "typecheck": "tsc --noEmit",          # Type check only (no output)
    "clean": "rm -rf dist"
  }
}
Enter fullscreen mode Exit fullscreen mode

Essential tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",                /* Output JS version */
    "module": "NodeNext",              /* Modern ESM module resolution */
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,                    /* Enable all strict checks! */
    "noUncheckedIndexedAccess": true,  /* Catch undefined array/object access */
    "noUnusedLocals": true,            /* Flag unused variables */
    "noUnusedParameters": true,        /* Flag unused parameters */
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,              /* Skip type checking .d.ts files (faster) */
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "declaration": true,               /* Generate .d.ts for library consumers */
    "declarationMap": true,
    "sourceMap": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]                /* Path aliases */
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

Type Basics That Actually Matter

Primitives and Basic Types

// Primitive types (lowercase!)
let name: string = "Alice";
let age: number = 30;
let isActive: boolean = true;
let data: null = null;
let nothing: undefined = undefined;

// bigint and symbol (less common but good to know)
const bigNum: bigint = 9007199254740991n;
const sym: symbol = Symbol("unique");

// Type inference — you don't always need to annotate!
let inferred = "hello"; // TS knows this is string
// Only annotate when:
// → Declaring without assignment (function params, class properties)
// → You want a MORE specific type than inference gives
// → Public API boundaries (function signatures)

// Arrays
const numbers: number[] = [1, 2, 3];
const strings: Array<string> = ["a", "b", "c"]; // Generic syntax (same thing)
const matrix: number[][] = [[1, 2], [3, 4]];   // Nested arrays

// Tuples (fixed-length, typed positions)
let point: [number, number] = [10, 20];
let httpResponse: [number, string] = [200, "OK"];
// Optional tuple elements:
let optionalTuple: [string, number?] = ["hello"]; // Second element optional

// Objects
const user: { name: string; age: number; email?: string } = {
  name: "Alice",
  age: 30,
  // email is optional (marked with ?)
};

// Type alias (reusable object shape)
type User = {
  id: string;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
  createdAt: Date;
  preferences?: {
    theme: "light" | "dark";
    notifications: boolean;
  };
};
Enter fullscreen mode Exit fullscreen mode

Functions

// Function type annotations
function add(a: number, b: number): number {
  return a + b;
}

// Return type can often be inferred
function greet(name: string) {
  return `Hello, ${name}`; // TS infers return type as string
}

// Arrow functions
const multiply = (a: number, b: number): number => a * b;

// Default parameters
function createUser(name: string, role: string = "viewer"): User {
  return { id: crypto.randomUUID(), name, role } as User;
}

// Rest parameters
function sum(...numbers: number[]): number {
  return numbers.reduce((total, n) => total + n, 0);
}

// Destructured parameters with types
function printUser({ name, age }: { name: string; age: number }): void {
  console.log(`${name} is ${age} years old`);
}

// Function overloads (different behavior based on input types)
function processInput(input: string): string[];
function processInput(input: string[]): string;
function processInput(input: string | string[]): string[] | string {
  if (Array.isArray(input)) return input.join(", ");
  return input.split(/\s+/);
}

// Callable signatures (for callback types)
type FilterFn<T> = (item: T, index: number) => boolean;

function filter<T>(items: T[], predicate: FilterFn<T>): T[] {
  return items.filter(predicate);
}
Enter fullscreen mode Exit fullscreen mode

Union Types & Narrowing

// Union: value can be one of several types
let id: string | number;
id = "abc123";       // OK
id = 456;            // OK
id = true;           // ERROR! boolean not allowed

// Literal unions (great for constants/flags)
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type Status = "pending" | "active" | "archived";

// Discriminated unions (THE most powerful pattern!)
type Success<T> = { status: "success"; data: T };
type ErrorResult = { status: "error"; error: string; code: number };
type Loading = { status: "loading" };

type ApiResponse<T> = Success<T> | ErrorResult | Loading;

function handleResponse<T>(response: ApiResponse<T>) {
  // TypeScript knows which properties exist in each branch!
  switch (response.status) {
    case "success":
      console.log(response.data);      // ✅ data exists here
      break;
    case "error":
      console.log(response.error, response.code); // ✅ both exist
      break;
    case "loading":
      console.log("Loading...");       // No extra properties
      break;
  }
}

// Type narrowing techniques
function narrowExample(value: string | number | null) {
  // typeof check
  if (typeof value === "string") {
    value.toUpperCase(); // TS knows it's string here
  }

  // Truthiness check
  if (value) {
    value.toString(); // Not null or undefined here
  }

  // Array.isArray()
  if (Array.isArray(value)) { /* ... */ }

  // instanceof
  if (value instanceof Date) { /* ... */ }

  // In operator
  if ("name" in value) { /* ... */ }

  // Custom type guard function
}
Enter fullscreen mode Exit fullscreen mode

Advanced Types You'll Use Daily

// Partial<T> — make all properties optional
function updateUser(id: string, updates: Partial<User>): User {
  const existing = getUser(id);
  return { ...existing, ...updates }; // Merge partial update
}
updateUser("usr_123", { email: "new@email.com" }); // Only email needed!

// Required<T> — make all properties required
// Omit<T, K> — remove specific keys
// Pick<T, K> — keep only specific keys
type UserPreview = Pick<User, "name" | "role">; // Just name + role
type CreateUserInput = Omit<User, "id" | "createdAt">; // Everything except id/createdAt

// Record<K, V> — dictionary/object type
const rolePermissions: Record<string, string[]> = {
  admin: ["read", "write", "delete"],
  editor: ["read", "write"],
  viewer: ["read"],
};

// ReturnType<T> — extract return type of a function
type UserFromDb = typeof getUserFromDb; // Function type itself
type UserData = ReturnType<typeof getUserFromDb>; // What the function returns

// Parameters<T> — extract parameter types as tuple
type FetchParams = Parameters<typeof fetch>;
// [RequestInfo | URL, RequestInit?]

// Awaited<T> — unwrap Promise type
type ResolvedUser = Awaited<Promise<User>>; // = User (not Promise<User>)

// Template literal types
type EventName = `on${Capitalize<string>}`;
type CssProperty = `--${string}`;

// Mapped types
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Nullable<T> = { [K in keyof T]: T[K] | null };

// Conditional types
type IsString<T> = T extends string ? true : false;
type Result = IsString<"hello">; // true
type Result2 = IsString<number>; // false

// Utility: Extract response data type from API call
async function apiCall<T>(url: string): Promise<T> {
  const res = await fetch(url);
  return res.json();
}

type UsersResponse = Awaited<ReturnType<typeof apiCall<User[]>>>;
// = User[]
Enter fullscreen mode Exit fullscreen mode

Generics: Reusable Type Logic

// Basic generic function
function identity<T>(arg: T): T {
  return arg;
}
identity("hello"); // T = string
identity(42);      // T = number

// Generic constraints
interface HasId {
  id: string;
}

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

// Multiple type parameters
function merge<K, V>(keys: K[], values: V[]): Map<K, V> {
  const map = new Map<K, V>();
  keys.forEach((key, i) => map.set(key, values[i]));
  return map;
}

// Generic with default type
interface ApiResponse<T = unknown> {
  success: boolean;
  data: T;
  message?: string;
}

// Practical: Typed event emitter
class EventEmitter<Events extends Record<string, unknown[]>> {
  #listeners = new Map<keyof Events, Function[]>();

  on<E extends keyof Events>(event: E, listener: (...args: Events[E]) => void) {
    if (!this.#listeners.has(event)) this.#listeners.set(event, []);
    this.#listeners.get(event)!.push(listener);
  }

  emit<E extends keyof Events>(event: E, ...args: Events[E]) {
    this.#listeners.get(event)?.forEach(fn => fn(...args));
  }
}

// Usage with full type safety:
type AppEvents = {
  "user:login": [user: User, timestamp: number];
  "user:logout": [userId: string];
  "error": [error: Error, context: string];
};

const bus = new EventEmitter<AppEvents>();
bus.on("user:login", (user, timestamp) => {
  // user is typed as User, timestamp as number
});
bus.emit("error", new Error("test"), "login"); // Fully type-checked!
Enter fullscreen mode Exit fullscreen mode

Error Handling

// Result pattern instead of try/catch (functional approach)
type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

async function safeFetch<T>(url: string): Promise<Result<T>> {
  try {
    const res = await fetch(url);
    if (!res.ok) return { success: false, error: new Error(`HTTP ${res.status}`) };
    const data = await res.json() as T;
    return { success: true, data };
  } catch (e) {
    return { success: false, error: e instanceof Error ? e : new Error(String(e)) };
  }
}

// Usage: no try/catch needed at call site!
const result = await safeFetch<User>("/api/user/123");
if (result.success) {
  console.log(result.data.name); // TS knows data exists
} else {
  console.error(result.error.message); // TS knows error exists
}

// Never type (exhaustive checking)
type Role = "admin" | "editor" | "viewer";

function getPermission(role: Role): string[] {
  switch (role) {
    case "admin": return ["*"];
    case "editor": return ["read", "write"];
    case "viewer": return ["read"];
    default:
      // If you add a new Role later, TS will error here!
      const _exhaustive: never = role;
      throw new Error(`Unknown role: ${_exhaustive}`);
  }
}

// assert functions (narrowing via assertion)
function assert(condition: unknown, msg?: string): asserts condition {
  if (!condition) throw new Error(msg || "Assertion failed");
}

function processUser(user: User | null) {
  assert(user, "User is required");
  // After this line, TS knows user is NOT null
  console.log(user.name);
}
Enter fullscreen mode Exit fullscreen mode

Migration Strategy: JS → TS

Step 1: Add TypeScript to existing JS project
  → npm install --save-dev typescript @types/node
  → npx tsc --init (with allowJs: true, strict: false initially)

Step 2: Rename files .js → .ts one module at a time
  → Start with core utilities (no dependencies)
  → Work outward to modules with more dependencies
  → Don't do everything at once!

Step 3: Gradually enable strict options
  → Start: strict: false (permissive)
  → Then: noImplicitAny (catch untyped values)
  → Then: strictNullChecks (catch null/undefined issues)
  → Finally: strict: true (full safety)

Step 4: Add types to third-party libraries
  → npm install --save-dev @types/express @types/lodash etc.
  → Or write your own .d.ts declaration files

Key principle: Incremental adoption.
You don't need to convert everything at once.
Enter fullscreen mode Exit fullscreen mode

Are you team JavaScript or team TypeScript? When do you choose each?

Follow @armorbreak for more practical developer guides.

Top comments (0)