}

TypeScript for JavaScript Developers 2026: Practical Migration Guide

TL;DR: TypeScript has become the de-facto standard for professional JavaScript development in 2026. This guide walks you through everything a working JavaScript developer needs: type fundamentals, interfaces, generics, discriminated unions, utility types, Stage 3 decorators, and a step-by-step migration strategy from an existing JS codebase. You will finish with a working tsconfig.json, confidence in strict mode, and practical patterns for typed API responses, Redux, and event handlers.


Why TypeScript in 2026?

TypeScript crossed 30 million npm downloads per week in early 2026 and now appears in 78 % of professional JavaScript projects, according to the State of JS 2025 survey. That adoption rate is not a coincidence — it is the result of a decade of compounding benefits:

  • Catch bugs before runtime. The TypeScript compiler rejects entire categories of mistakes — undefined property access, wrong argument types, missing return statements — that JavaScript only surfaces at runtime, often in production.
  • Better tooling. Editors use TypeScript's language server for autocompletion, refactoring, and inline docs even in plain .js files. Adding types makes that experience dramatically richer.
  • Self-documenting code. A function signature like fetchUser(id: string): Promise<User> communicates intent and contract without a single line of JSDoc.
  • Safer large-scale refactoring. Renaming a field or changing a function signature causes a cascade of type errors that guides you to every call site — something that is impossible in pure JavaScript.
  • Ecosystem alignment. Major frameworks (React, Vue, Angular, Svelte, Next.js, Remix, NestJS) ship first-class TypeScript support and publish their own type definitions.

If you are a JavaScript developer who has been putting off learning TypeScript, 2026 is the year that "I'll do it later" starts costing you real opportunities. This guide makes the transition as concrete as possible.


1. Setup: tsconfig.json Explained Field by Field

The compiler configuration file tsconfig.json controls how TypeScript processes your code. Run npx tsc --init to generate a starting point, then understand each field:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "./src",
    "outDir": "./dist",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,
    "forceConsistentCasingInFileNames": true,
    "esModuleInterop": true,
    "skipLibCheck": false,
    "isolatedModules": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
FieldWhat it does
targetThe JavaScript version emitted. ES2022 gives you top-level await, class fields, and at() without polyfills in modern Node/browser environments.
libType definitions included automatically. DOM adds browser globals; omit it for pure Node projects.
moduleModule format for output. NodeNext is correct for Node 18+ with "type": "module" in package.json.
moduleResolutionHow imports are resolved. Must match module.
rootDir / outDirSource and compiled output directories. Keeps your repo clean.
declarationEmit .d.ts files — required if you publish a library.
declarationMapMaps .d.ts back to source, so "Go to Definition" in editors lands in .ts not the compiled file.
sourceMapEnables debugging TypeScript source in browser devtools and Node.
strictMaster switch that enables a suite of safety flags (covered in section 13). Never disable this.
noUncheckedIndexedAccessArray and object index access returns T \| undefined, not just T. Prevents countless off-by-one bugs.
noImplicitReturnsAll code paths in a function must return a value.
esModuleInteropAllows import fs from 'fs' instead of import * as fs from 'fs'.
isolatedModulesEnsures each file can be transpiled independently — required by Vite, esbuild, and SWC.

2. Basic Types vs JavaScript

TypeScript adds a type annotation syntax on top of JavaScript. Most of the time you write the same code, just with types attached.

JavaScript (before):

function greet(name) {
  return "Hello, " + name;
}

const scores = [10, 20, 30];
const user = { id: 1, active: true };

TypeScript (after):

function greet(name: string): string {
  return "Hello, " + name;
}

const scores: number[] = [10, 20, 30];
const user: { id: number; active: boolean } = { id: 1, active: true };

The primitive types map directly to JavaScript's runtime types:

TypeScript typeJavaScript typeof
string"string"
number"number"
boolean"boolean"
bigint"bigint"
symbol"symbol"
undefined"undefined"
null— (typeof null is "object", a JS quirk)

Two types exist only in TypeScript's type system:

  • any — opts out of type checking entirely. Avoid it; it defeats the purpose of TypeScript.
  • unknown — like any, but you must narrow it before using it. Use unknown for values of genuinely unknown shape (parsed JSON, catch clause errors).
// BAD: any turns off all checks
function parseInput(raw: any) {
  return raw.toUpperCase(); // no error even if raw is a number
}

// GOOD: unknown forces you to verify
function parseInput(raw: unknown): string {
  if (typeof raw !== "string") throw new TypeError("Expected string");
  return raw.toUpperCase();
}

Arrays and tuples:

const tags: string[] = ["ts", "js"];           // array of strings
const pair: [string, number] = ["Alice", 30];  // tuple: fixed length and types

void and never:

function logMessage(msg: string): void { console.log(msg); } // returns nothing
function fail(msg: string): never { throw new Error(msg); }  // never returns

3. Interfaces vs Type Aliases

Both interface and type can describe the shape of an object. The choice between them is a matter of convention and capability.

Interface

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

Interfaces can be extended and can be merged (declaration merging). This makes them the right choice for public API contracts and for types that libraries expect consumers to extend.

interface AdminUser extends User {
  permissions: string[];
}

// Declaration merging — useful for augmenting third-party types
interface Window {
  analytics: AnalyticsClient;
}

Type Alias

type Point = { x: number; y: number };

// Type aliases can represent non-object shapes
type ID = string | number;
type Callback = (err: Error | null, result: string) => void;

Type aliases support union and intersection types, computed properties, and mapped types — things interfaces cannot express.

Rule of thumb: - Use interface for object shapes that represent entities (User, Product, Order) or that may be extended. - Use type for unions, intersections, function types, and aliases of primitives.


4. Generics: Reusable Typed Functions and Classes

Generics let you write a function or class once and have it work correctly across many types — without falling back to any.

JavaScript (before) — loses type information:

function first(arr) {
  return arr[0];
}
const x = first([1, 2, 3]); // x is any — editor can't help you

TypeScript (after) — type flows through:

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const x = first([1, 2, 3]);    // x is number | undefined
const y = first(["a", "b"]);   // y is string | undefined

Generic Constraints

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

const user = { id: 1, name: "Alice" };
const name = getProperty(user, "name"); // string — TypeScript knows the return type
// getProperty(user, "age"); // Error: "age" does not exist on type

Generic Classes

class Repository<T extends { id: number }> {
  private items: T[] = [];

  save(item: T): void {
    const index = this.items.findIndex((i) => i.id === item.id);
    if (index >= 0) {
      this.items[index] = item;
    } else {
      this.items.push(item);
    }
  }

  findById(id: number): T | undefined {
    return this.items.find((i) => i.id === id);
  }
}

const userRepo = new Repository<User>();
userRepo.save({ id: 1, name: "Alice", createdAt: new Date() });

Generic Interfaces

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

type UserResponse = ApiResponse<User>;
type UserListResponse = ApiResponse<User[]>;

5. Union Types, Intersection Types, and Discriminated Unions

Union Types

A union type allows a value to be one of several types:

type StringOrNumber = string | number;

function format(value: StringOrNumber): string {
  if (typeof value === "string") return value.toUpperCase();
  return value.toFixed(2);
}

Intersection Types

An intersection type combines multiple types into one. The resulting type has all members of each constituent type:

type Timestamped = { createdAt: Date; updatedAt: Date };
type Identifiable = { id: string };

type AuditedEntity = Identifiable & Timestamped;
// AuditedEntity has: id, createdAt, updatedAt

function withTimestamps<T>(base: T): T & Timestamped {
  return { ...base, createdAt: new Date(), updatedAt: new Date() };
}

Discriminated Unions

A discriminated union (also called a tagged union) is a pattern where each member of the union carries a literal type field — the "discriminant" — that uniquely identifies it:

type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: User[] };
type ErrorState   = { status: "error";   error: string };

type FetchState = LoadingState | SuccessState | ErrorState;

function render(state: FetchState): string {
  switch (state.status) {
    case "loading": return "Loading...";
    case "success": return `Found ${state.data.length} users`;
    case "error":   return `Error: ${state.error}`;
    // TypeScript enforces exhaustiveness — add a new status and you get a compile error here
  }
}

Discriminated unions are one of TypeScript's most powerful patterns. They model state machines precisely and make impossible states unrepresentable in the type system.


6. Type Narrowing and Type Guards

TypeScript tracks which types are possible at each point in your code. This is called control flow analysis, and the act of refining a broader type to a narrower one is called type narrowing.

Built-in Narrowing

function processId(id: string | number) {
  if (typeof id === "string") {
    // Here TypeScript knows id is string
    console.log(id.toUpperCase());
  } else {
    // Here TypeScript knows id is number
    console.log(id.toFixed(2));
  }
}

instanceof Narrowing

function handleError(err: unknown) {
  if (err instanceof Error) {
    console.log(err.message); // err is Error here
  } else {
    console.log(String(err));
  }
}

Custom Type Guards

When built-in narrowing is not enough, write a type predicate function:

interface Cat { kind: "cat"; purr(): void }
interface Dog { kind: "dog"; bark(): void }
type Animal = Cat | Dog;

function isCat(animal: Animal): animal is Cat {
  return animal.kind === "cat";
}

function interact(animal: Animal) {
  if (isCat(animal)) {
    animal.purr(); // TypeScript knows it's a Cat
  } else {
    animal.bark(); // TypeScript knows it's a Dog
  }
}

Assertion Functions

function assertDefined<T>(val: T | undefined, name: string): asserts val is T {
  if (val === undefined) throw new Error(`${name} must be defined`);
}

const config = getConfig(); // Config | undefined
assertDefined(config, "config");
// After this line, TypeScript knows config is Config (not Config | undefined)

7. Utility Types

TypeScript ships a library of generic utility types that transform existing types into new ones. These save enormous amounts of boilerplate.

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
  inStock: boolean;
}
UtilityResultUse case
Partial<Product>All fields optionalPATCH request body
Required<Product>All fields requiredInternal canonical form
Readonly<Product>All fields readonlyFrozen config objects
Pick<Product, "id" \| "name">Only those fieldsLightweight list view
Omit<Product, "description">All fields except thoseForm state without heavy fields
Record<string, Product>Object keyed by stringLookup maps / caches

ReturnType and Parameters:

function createOrder(userId: number, items: string[]): { orderId: string } {
  return { orderId: crypto.randomUUID() };
}

type OrderResult = ReturnType<typeof createOrder>;   // { orderId: string }
type OrderArgs   = Parameters<typeof createOrder>;   // [number, string[]]

NonNullable:

type MaybeString = string | null | undefined;
type DefiniteString = NonNullable<MaybeString>; // string

Combining utilities:

// A type for updating a product: all fields optional except id (required)
type ProductUpdate = Required<Pick<Product, "id">> & Partial<Omit<Product, "id">>;

8. Enums vs Const Objects

TypeScript has two ways to represent a fixed set of named constants. They have different trade-offs.

Enums

enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}

function move(dir: Direction) { /* ... */ }
move(Direction.Up);
move("UP"); // Error — string is not assignable to Direction

Downside: String enums compile to real JavaScript objects and add bytes to your bundle. Numeric enums have reverse mapping surprises. The TypeScript team has not deprecated them, but many teams avoid them.

Const Objects (preferred in 2026)

const Direction = {
  Up: "UP",
  Down: "DOWN",
  Left: "LEFT",
  Right: "RIGHT",
} as const;

type Direction = typeof Direction[keyof typeof Direction]; // "UP" | "DOWN" | "LEFT" | "RIGHT"

function move(dir: Direction) { /* ... */ }
move(Direction.Up);  // OK
move("UP");          // Also OK — the literal "UP" is part of the union

Const objects are plain JavaScript, zero runtime overhead, and work naturally with string literal unions. Use them unless you have a specific reason to reach for an enum.


9. Decorators (Stage 3): Practical Class Decorator Example

The TC39 Stage 3 decorators proposal reached stable status and TypeScript 5.0+ implements it with "experimentalDecorators": false (the new spec-compliant decorators need no flag). Decorators are a first-class pattern in NestJS, Angular, and MobX.

A logging decorator for class methods:

function log(target: unknown, context: ClassMethodDecoratorContext) {
  const methodName = String(context.name);

  return function (this: unknown, ...args: unknown[]) {
    console.log(`[${methodName}] called with`, args);
    // @ts-expect-error — calling original method
    const result = (target as Function).apply(this, args);
    console.log(`[${methodName}] returned`, result);
    return result;
  };
}

class OrderService {
  @log
  createOrder(userId: string, items: string[]): string {
    // business logic
    return `order-${Date.now()}`;
  }
}

const svc = new OrderService();
svc.createOrder("user-1", ["item-a", "item-b"]);
// [createOrder] called with ["user-1", ["item-a", "item-b"]]
// [createOrder] returned "order-1715695200000"

A class decorator that registers a service:

const registry = new Map<string, new (...args: unknown[]) => unknown>();

function Injectable(name: string) {
  return function <T extends new (...args: unknown[]) => unknown>(
    target: T,
    _context: ClassDecoratorContext
  ) {
    registry.set(name, target);
    return target;
  };
}

@Injectable("userService")
class UserService {
  findAll(): User[] { return []; }
}

// Later in a DI container:
const ServiceClass = registry.get("userService")!;
const service = new ServiceClass();

10. Migrating a JS Project to TypeScript: Step by Step

Migration does not have to be all-or-nothing. The recommended strategy is incremental: add TypeScript to your build, convert one file at a time, and progressively tighten the compiler.

Step 1 — Install TypeScript and type definitions

npm install --save-dev typescript @types/node
npx tsc --init

Step 2 — Enable allowJs mode

Add "allowJs": true and "checkJs": true to compilerOptions. This lets TypeScript compile your existing .js files and provides basic type checking via JSDoc inference — without renaming a single file.

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "strict": false,
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

Fix any errors that surface. These are real bugs TypeScript found in your existing JS.

Step 3 — Convert files one at a time

Rename src/api/users.js to src/api/users.ts. Add types to function signatures. TypeScript will infer the rest. Repeat file by file, starting with utility modules that have no dependencies, then working upward through the dependency graph.

// Before: users.js
export function findUser(id) {
  return db.query("SELECT * FROM users WHERE id = ?", [id]);
}
// After: users.ts
import type { User } from "../types";

export async function findUser(id: number): Promise<User | undefined> {
  return db.query<User>("SELECT * FROM users WHERE id = ?", [id]);
}

Step 4 — Add a types.ts (or types/ directory)

Centralise your domain types early. Every interface defined once becomes a shared contract across the codebase.

// src/types.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user" | "guest";
  createdAt: Date;
}

export interface Order {
  id: string;
  userId: number;
  items: OrderItem[];
  total: number;
  status: "pending" | "fulfilled" | "cancelled";
}

Step 5 — Tighten compiler flags incrementally

Do not try to go from zero to "strict": true in one step on a large codebase. Instead, enable flags one at a time and fix errors before enabling the next:

  1. "noImplicitAny": true — eliminates implicit any (the highest-value flag)
  2. "strictNullChecks": true — distinguishes string from string | null | undefined
  3. "strictFunctionTypes": true — checks function parameter types contravariantly
  4. "strict": true — enables everything above plus more
  5. "noUncheckedIndexedAccess": true — the final boss; requires handling undefined from every array access

Step 6 — Remove allowJs once migration is complete

When all files are .ts, remove allowJs and checkJs from your config. You are now a full TypeScript project.


11. tsconfig Strict Mode: What Each Flag Does

"strict": true is a shorthand that enables the following flags:

FlagWhat it catches
strictNullChecksPrevents using null or undefined where a value is expected. The single most impactful safety flag.
noImplicitAnyVariables that cannot be inferred get an error instead of silently becoming any.
strictFunctionTypesMethod parameters are checked covariantly; function types are checked contravariantly. Prevents callback type mismatches.
strictBindCallApplybind, call, and apply are type-checked against the original function's signature.
strictPropertyInitializationClass properties must be initialised in the constructor or marked with !.
noImplicitThisRaises an error when this would be any inside a function.
alwaysStrictEmits "use strict" at the top of every output file.
useUnknownInCatchVariablesChanges catch clause variable type from any to unknown (added in TS 4.4).

Beyond strict, the flags worth adding to every project:

"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true

12. Common Patterns in Practice

Typed API Responses

interface ApiResponse<T> {
  data: T;
  error: string | null;
  statusCode: number;
}

async function fetchUsers(): Promise<ApiResponse<User[]>> {
  const res = await fetch("/api/users");
  if (!res.ok) {
    return { data: [], error: `HTTP ${res.status}`, statusCode: res.status };
  }
  const data: User[] = await res.json();
  return { data, error: null, statusCode: 200 };
}

// Usage — TypeScript knows exactly what you have
const result = await fetchUsers();
if (result.error) {
  console.error(result.error);
} else {
  result.data.forEach((user) => console.log(user.name));
}

Typed Event Handlers (React)

import { ChangeEvent, FormEvent, useState } from "react";

interface LoginForm {
  email: string;
  password: string;
}

function LoginPage() {
  const [form, setForm] = useState<LoginForm>({ email: "", password: "" });

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setForm((prev) => ({ ...prev, [name]: value }));
  };

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    await login(form.email, form.password);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" value={form.email} onChange={handleChange} />
      <input name="password" type="password" value={form.password} onChange={handleChange} />
      <button type="submit">Login</button>
    </form>
  );
}

Typed Redux (Redux Toolkit)

import { createSlice, PayloadAction, createAsyncThunk } from "@reduxjs/toolkit";

interface UserState {
  users: User[];
  selectedId: number | null;
  status: "idle" | "loading" | "error";
}

const initialState: UserState = {
  users: [],
  selectedId: null,
  status: "idle",
};

export const fetchUsers = createAsyncThunk("users/fetchAll", async () => {
  const res = await fetch("/api/users");
  return (await res.json()) as User[];
});

const userSlice = createSlice({
  name: "users",
  initialState,
  reducers: {
    selectUser(state, action: PayloadAction<number>) {
      state.selectedId = action.payload;
    },
    clearSelection(state) {
      state.selectedId = null;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => { state.status = "loading"; })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = "idle";
        state.users = action.payload; // payload is User[] — fully typed
      })
      .addCase(fetchUsers.rejected, (state) => { state.status = "error"; });
  },
});

export const { selectUser, clearSelection } = userSlice.actions;
export default userSlice.reducer;

// Typed selectors
import type { RootState } from "../store";
export const selectAllUsers = (state: RootState): User[] => state.users.users;
export const selectCurrentUser = (state: RootState): User | undefined =>
  state.users.users.find((u) => u.id === state.users.selectedId);

Typed Node.js HTTP Handler (Express)

import express, { Request, Response, NextFunction } from "express";

interface CreateUserBody {
  name: string;
  email: string;
  role?: "admin" | "user";
}

const router = express.Router();

router.post(
  "/users",
  async (
    req: Request<{}, {}, CreateUserBody>,
    res: Response<ApiResponse<User>>,
    next: NextFunction
  ) => {
    try {
      const user = await userService.create(req.body);
      res.status(201).json({ data: user, error: null, statusCode: 201 });
    } catch (err) {
      next(err);
    }
  }
);

FAQ

Q: Do I need TypeScript if my project uses JSDoc types? JSDoc + checkJs gets you a large portion of TypeScript's safety without changing file extensions, and it's a valid long-term strategy for small projects. For anything beyond a few thousand lines, full TypeScript is worth the overhead because you get generics, discriminated unions, and utility types in their full form — none of which are fully expressible in JSDoc.

Q: Does TypeScript slow down my build? TypeScript type-checking (tsc --noEmit) can be slow on very large projects. The solution is to separate transpilation from type-checking: use esbuild, SWC, or Vite (which use isolatedModules and skip types during compilation) for fast dev builds, and run tsc --noEmit in CI or as a pre-commit hook.

Q: Should I use any when I'm stuck? Only as a last resort and never in public API types. Prefer unknown (forces you to narrow), a more specific union type, or a generic. If you must use any, add a // eslint-disable-next-line @typescript-eslint/no-explicit-any comment with a brief explanation so future maintainers understand the intent.

Q: What is the difference between type and interface really? The practical differences in 2026: (1) interfaces support declaration merging, types do not; (2) type aliases can represent unions and intersections directly, interfaces cannot; (3) error messages for interfaces are often clearer because the interface name appears in the message. Pick one convention for your team and stick to it.

Q: Are TypeScript types available at runtime? No. TypeScript types are erased during compilation. They exist only in the source code and have zero runtime cost. If you need runtime type checking (e.g., for user input), use a validation library like Zod, Valibot, or ArkType that can derive TypeScript types from runtime schemas.

Q: Is TypeScript compatible with all npm packages? Most packages either include types in their main package ("types" field in package.json) or have a corresponding @types/package-name package in the DefinitelyTyped repository. A small number of older packages have no types at all; for those you can write a minimal .d.ts declaration file in a @types/ directory at the root of your project.

Q: Do I need to use classes in TypeScript? No. TypeScript works equally well with functional programming styles. Classes are most useful when you need to express inheritance hierarchies, use decorators, or integrate with class-based frameworks like NestJS or Angular.


Sources

Leonardo Lazzaro

Software engineer and technical writer. 10+ years experience in DevOps, Python, and Linux systems.

More articles by Leonardo Lazzaro