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
returnstatements — 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
.jsfiles. 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"]
}
| Field | What it does |
|---|---|
target | The JavaScript version emitted. ES2022 gives you top-level await, class fields, and at() without polyfills in modern Node/browser environments. |
lib | Type definitions included automatically. DOM adds browser globals; omit it for pure Node projects. |
module | Module format for output. NodeNext is correct for Node 18+ with "type": "module" in package.json. |
moduleResolution | How imports are resolved. Must match module. |
rootDir / outDir | Source and compiled output directories. Keeps your repo clean. |
declaration | Emit .d.ts files — required if you publish a library. |
declarationMap | Maps .d.ts back to source, so "Go to Definition" in editors lands in .ts not the compiled file. |
sourceMap | Enables debugging TypeScript source in browser devtools and Node. |
strict | Master switch that enables a suite of safety flags (covered in section 13). Never disable this. |
noUncheckedIndexedAccess | Array and object index access returns T \| undefined, not just T. Prevents countless off-by-one bugs. |
noImplicitReturns | All code paths in a function must return a value. |
esModuleInterop | Allows import fs from 'fs' instead of import * as fs from 'fs'. |
isolatedModules | Ensures 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 type | JavaScript 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— likeany, but you must narrow it before using it. Useunknownfor values of genuinely unknown shape (parsed JSON,catchclause 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;
}
| Utility | Result | Use case |
|---|---|---|
Partial<Product> | All fields optional | PATCH request body |
Required<Product> | All fields required | Internal canonical form |
Readonly<Product> | All fields readonly | Frozen config objects |
Pick<Product, "id" \| "name"> | Only those fields | Lightweight list view |
Omit<Product, "description"> | All fields except those | Form state without heavy fields |
Record<string, Product> | Object keyed by string | Lookup 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:
"noImplicitAny": true— eliminates implicitany(the highest-value flag)"strictNullChecks": true— distinguishesstringfromstring | null | undefined"strictFunctionTypes": true— checks function parameter types contravariantly"strict": true— enables everything above plus more"noUncheckedIndexedAccess": true— the final boss; requires handlingundefinedfrom 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:
| Flag | What it catches |
|---|---|
strictNullChecks | Prevents using null or undefined where a value is expected. The single most impactful safety flag. |
noImplicitAny | Variables that cannot be inferred get an error instead of silently becoming any. |
strictFunctionTypes | Method parameters are checked covariantly; function types are checked contravariantly. Prevents callback type mismatches. |
strictBindCallApply | bind, call, and apply are type-checked against the original function's signature. |
strictPropertyInitialization | Class properties must be initialised in the constructor or marked with !. |
noImplicitThis | Raises an error when this would be any inside a function. |
alwaysStrict | Emits "use strict" at the top of every output file. |
useUnknownInCatchVariables | Changes 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
- TypeScript Official Documentation
- TypeScript 5.x Release Notes
- State of JS 2025 — TypeScript section
- TC39 Decorators Proposal (Stage 3)
- DefinitelyTyped Repository
- Redux Toolkit TypeScript Quick Start
- Matt Pocock — Total TypeScript
- Effective TypeScript by Dan Vanderkam (O'Reilly, 2nd ed.)
- TypeScript Deep Dive by Basarat Ali Syed
- tsconfig reference — typescriptlang.org