Skip to main content

TypeScript

TypeScript is JavaScript with static types. It catches bugs at compile time instead of at runtime, provides better editor autocompletion, and makes large codebases easier to maintain. TypeScript can parse JavaScript syntax, so you can adopt it gradually, although strict type checking may still report errors in existing JavaScript.

Tip: This chapter is a high-level overview. For a deeper path through the language, continue with the full TypeScript beginners guide.

Why TypeScript?​

Consider this JavaScript function:

function add(a, b) {
return a + b;
}

add(2, 3); // 5
add("2", 3); // "23" - probably a bug

JavaScript happily concatenates a string and a number. TypeScript catches this:

function add(a: number, b: number): number {
return a + b;
}

add(2, 3); // 5
add("2", 3); // Error: Argument of type 'string' is not assignable to parameter of type 'number'

The : number annotations tell TypeScript what types are expected. The error appears in your editor before you even run the code.

What TypeScript gives you​

BenefitExplanation
Catch bugs earlyType errors are caught at compile time, not at runtime
Better autocompleteYour editor knows the shape of every object
Self-documenting codeTypes explain what a function expects and returns
Safer refactoringRename a property and see every place that breaks
Gradual adoptionAdd types to one file at a time - no big rewrite needed

Setting up TypeScript​

Installing​

npm init -y
npm install --save-dev typescript

This adds the TypeScript compiler (tsc) to your project.

Creating tsconfig.json​

npx tsc --init

This creates a tsconfig.json with sensible defaults. Here is a minimal configuration:

{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
OptionWhat it does
targetWhich JavaScript version to compile to
moduleModule system for the output
strictEnable all strict type checks (always use this)
outDirWhere compiled .js files go
rootDirWhere your .ts source files live

This configuration fits browser or bundler-based projects. For a Node-only project that imports other files directly, prefer "module": "NodeNext" and "moduleResolution": "NodeNext" so TypeScript matches Node's module rules.

Your first TypeScript file​

Create src/hello.ts:

const greeting: string = "Hello, TypeScript!";
console.log(greeting);

Compile and run:

npx tsc
node dist/hello.js

Result:

Hello, TypeScript!

tsc compiles .ts files into .js files in the dist/ folder. You run the .js output with Node.

Running TypeScript with Node's built-in type stripping​

Modern Node can run many .ts files by stripping type annotations. Node 22.6 introduced this behind an experimental flag; Node 23.6 and Node 22.18 enabled type stripping by default:

node --experimental-strip-types src/hello.ts # Node 22.6 - 22.17
node src/hello.ts # Node 22.18+ and 23.6+

This is not a replacement for type checking: it only removes erasable type syntax. Features that need JavaScript output generation, such as enums and constructor parameter properties, still need a compiler or runner such as tsc, tsx, or ts-node. Run npx tsc --noEmit in CI to catch type errors.

Using ts-node for development​

Compiling before every run is tedious. ts-node runs TypeScript directly:

npm install --save-dev ts-node
npx ts-node src/hello.ts

Result:

Hello, TypeScript!

Type annotations​

Type annotations tell TypeScript what type a value should be. The syntax is : Type after the variable name, parameter, or return value.

Primitive types​

const personName: string = "Ada";
const age: number = 36;
const active: boolean = true;
const nothing: null = null;
const missing: undefined = undefined;

Type inference​

TypeScript can often figure out the type automatically:

const personName = "Ada"; // TypeScript infers: string
const age = 36; // TypeScript infers: number
const active = true; // TypeScript infers: boolean

You do not need to annotate everything. TypeScript is smart enough to infer types from assigned values. Add explicit annotations when:

  • The type is not obvious from the value
  • You want to document the intent
  • You are declaring a variable without immediately assigning it
// Inference is enough
const count = 0;

// Annotation needed - no initial value
let username: string;
username = "ada";

Function types​

Annotate parameters and return types:

function multiply(a: number, b: number): number {
return a * b;
}

// Arrow function
const divide = (a: number, b: number): number => {
return a / b;
};

Optional parameters​

Use ? to mark a parameter as optional:

function greet(name: string, greeting?: string): string {
return `${greeting ?? "Hello"}, ${name}!`;
}

greet("Ada"); // "Hello, Ada!"
greet("Ada", "Welcome"); // "Welcome, Ada!"

Default parameters​

function greet(name: string, greeting: string = "Hello"): string {
return `${greeting}, ${name}!`;
}

void and never​

// void: function returns nothing
function log(message: string): void {
console.log(message);
}

// never: function never returns (always throws or loops forever)
function throwError(message: string): never {
throw new Error(message);
}

Arrays and tuples​

Arrays​

const numbers: number[] = [1, 2, 3];
const names: string[] = ["Ada", "Grace", "Alan"];

// Alternative syntax
const scores: Array<number> = [90, 85, 92];

Tuples​

Tuples are fixed-length arrays where each position has a specific type:

const user: [string, number] = ["Ada", 36];

const userName = user[0]; // string
const age = user[1]; // number

// Error: Type 'boolean' is not assignable to type 'string'
// user[0] = true;

Tuples are useful for returning multiple values from a function:

function getUser(): [string, number] {
return ["Ada", 36];
}

const [userName, age] = getUser();

Objects and interfaces​

Object types (inline)​

function printUser(user: { name: string; age: number }): void {
console.log(`${user.name} is ${user.age} years old`);
}

printUser({ name: "Ada", age: 36 });

Inline types work for simple cases, but they get unwieldy fast. Use interfaces instead.

Interfaces​

An interface describes the shape of an object:

interface User {
name: string;
age: number;
email: string;
}

function printUser(user: User): void {
console.log(`${user.name} (${user.email}), age ${user.age}`);
}

const ada: User = {
name: "Ada Lovelace",
age: 36,
email: "ada@example.com",
};

printUser(ada);

Result:

Ada Lovelace (ada@example.com), age 36

Optional properties​

interface User {
name: string;
age: number;
email?: string; // optional
}

const user: User = { name: "Grace", age: 85 }; // email is optional

Readonly properties​

interface Config {
readonly apiUrl: string;
readonly timeout: number;
}

const config: Config = { apiUrl: "https://api.example.com", timeout: 5000 };

// Error: Cannot assign to 'apiUrl' because it is a read-only property
// config.apiUrl = "https://other.com";

Extending interfaces​

interface User {
name: string;
email: string;
}

interface Admin extends User {
role: string;
permissions: string[];
}

const admin: Admin = {
name: "Ada",
email: "ada@example.com",
role: "superadmin",
permissions: ["read", "write", "delete"],
};

Type aliases​

type defines a name for any type - not just objects:

type ID = string | number;
type StringArray = string[];
type Callback = (data: string) => void;

type User = {
id: ID;
name: string;
};

type vs interface​

Featureinterfacetype
Object shapesYesYes
Extendingextends keywordIntersection (&)
Union typesNoYes
Primitives, tuplesNoYes
Declaration mergingYesNo

Rule of thumb: Use interface for object shapes. Use type for everything else (unions, tuples, primitives, complex composed types).

Union types​

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

type ID = string | number;

function printId(id: ID): void {
console.log(`ID: ${id}`);
}

printId(101); // ID: 101
printId("abc-42"); // ID: abc-42

Narrowing​

TypeScript narrows the type inside conditional branches:

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

printId("hello"); // HELLO
printId(42); // 42.00

Literal types​

type Direction = "up" | "down" | "left" | "right";

function move(direction: Direction): void {
console.log(`Moving ${direction}`);
}

move("up"); // Moving up
// move("diagonal"); // Error: Argument of type '"diagonal"' is not assignable

Discriminated unions​

A powerful pattern for modelling different cases:

interface Circle {
kind: "circle";
radius: number;
}

interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}

type Shape = Circle | Rectangle;

function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
}
}

console.log(area({ kind: "circle", radius: 5 })); // 78.539...
console.log(area({ kind: "rectangle", width: 4, height: 6 })); // 24

The kind property (called a discriminant) lets TypeScript know which variant you are working with inside each branch.

Generics​

Generics let you write code that works with any type while preserving type safety:

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

first([1, 2, 3]); // number | undefined
first(["a", "b", "c"]); // string | undefined

T is a type parameter - a placeholder that gets filled in when the function is called.

Generic interfaces​

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

interface User {
name: string;
email: string;
}

const response: ApiResponse<User> = {
data: { name: "Ada", email: "ada@example.com" },
status: 200,
message: "OK",
};

Generic constraints​

Use extends to restrict what types are allowed:

interface HasId {
id: number;
}

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

const users = [
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" },
];

findById(users, 1); // { id: 1, name: "Ada" }

T extends HasId means "T must have at least an id: number property."

Multiple type parameters​

function pair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}

pair("hello", 42); // [string, number]
pair(true, "yes"); // [boolean, string]

Utility types​

TypeScript includes built-in utility types for common transformations:

Partial<T> - all properties become optional​

interface User {
name: string;
email: string;
age: number;
}

function updateUser(user: User, updates: Partial<User>): User {
return { ...user, ...updates };
}

const ada: User = { name: "Ada", email: "ada@example.com", age: 36 };
const updated = updateUser(ada, { age: 37 });

Required<T> - all properties become required​

interface Config {
host?: string;
port?: number;
}

const fullConfig: Required<Config> = {
host: "localhost",
port: 3000,
};

Pick<T, Keys> - select specific properties​

type UserPreview = Pick<User, "name" | "email">;

const preview: UserPreview = {
name: "Ada",
email: "ada@example.com",
};

Omit<T, Keys> - exclude specific properties​

type UserWithoutEmail = Omit<User, "email">;

const user: UserWithoutEmail = {
name: "Ada",
age: 36,
};

Record<Keys, Value> - create an object type from keys and values​

type Role = "admin" | "editor" | "viewer";

const permissions: Record<Role, string[]> = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"],
};

Summary of utility types​

UtilityWhat it does
Partial<T>Makes all properties optional
Required<T>Makes all properties required
Readonly<T>Makes all properties readonly
Pick<T, K>Keeps only the specified properties
Omit<T, K>Removes the specified properties
Record<K, V>Creates a type with keys K and values V
ReturnType<F>Extracts the return type of a function
Parameters<F>Extracts the parameter types of a function

Enums​

Enums define a set of named constants:

enum Direction {
Up,
Down,
Left,
Right,
}

function move(direction: Direction): void {
console.log(`Moving ${Direction[direction]}`);
}

move(Direction.Up); // Moving Up
move(Direction.Right); // Moving Right

String enums​

enum Status {
Active = "ACTIVE",
Inactive = "INACTIVE",
Pending = "PENDING",
}

function printStatus(status: Status): void {
console.log(status);
}

printStatus(Status.Active); // ACTIVE

const enum - inlined at compile time​

const enum Color {
Red = "RED",
Green = "GREEN",
Blue = "BLUE",
}

const c = Color.Red; // with tsc defaults, compiled to: const c = "RED"

Tip: Many TypeScript developers prefer union types (type Direction = "up" | "down" | "left" | "right") over enums. They are simpler, produce no runtime code, and work better with type narrowing.

Type assertions​

Sometimes you know more about a type than TypeScript does:

const input = document.getElementById("username") as HTMLInputElement;
input.value = "Ada";

Without the assertion, TypeScript only knows getElementById returns HTMLElement | null. With as HTMLInputElement, you tell TypeScript it is specifically an input element.

Use assertions sparingly. They override the type checker - if you are wrong, you will get runtime errors.

Non-null assertion​

The ! operator tells TypeScript a value is not null or undefined:

const element = document.getElementById("app")!;

For this expression, element becomes HTMLElement instead of HTMLElement | null. It does not prove the element is a specific subtype, such as HTMLInputElement. Again, use it only when you are certain the value exists.

Working with modules​

TypeScript uses the same import/export syntax as modern JavaScript:

// math.ts
export function add(a: number, b: number): number {
return a + b;
}

export function multiply(a: number, b: number): number {
return a * b;
}
// main.ts
import { add, multiply } from "./math.js";

console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20

Note: Use .js extensions in import paths even when importing .ts files. TypeScript resolves these to the compiled .js output.

Exporting types​

// types.ts
export interface User {
name: string;
email: string;
}

export type ID = string | number;
// user-service.ts
import type { User, ID } from "./types.js";

function getUser(id: ID): User {
return { name: "Ada", email: "ada@example.com" };
}

import type tells TypeScript (and bundlers) that this import is types only and can be erased from the output.

Working with third-party libraries​

Many npm packages include TypeScript types. For those that do not, install the type definitions separately:

# The package itself
npm install express

# Type definitions (from DefinitelyTyped)
npm install --save-dev @types/express

The @types scope on npm hosts community-maintained type definitions. Most popular packages have them.

Checking if types exist​

# Search for type definitions
npm search @types/lodash

Or visit https://www.typescriptlang.org/dt/search to search DefinitelyTyped.

Strict mode options​

The "strict": true flag in tsconfig.json enables several checks at once:

FlagWhat it catches
strictNullChecksnull and undefined are not assignable to other types
noImplicitAnyReports declarations, especially parameters, that would otherwise become any
strictFunctionTypesStricter checks on function parameter types
strictPropertyInitializationClass properties must be initialized in the constructor
noImplicitThisthis must have an explicit type in functions

Always use strict: true. It catches the most bugs and is the recommended setting for all TypeScript projects.

Common patterns​

Typing event handlers (browser)​

const button = document.getElementById("submit") as HTMLButtonElement;

button.addEventListener("click", (event: MouseEvent) => {
console.log("Button clicked at:", event.clientX, event.clientY);
});

const input = document.getElementById("search") as HTMLInputElement;

input.addEventListener("input", (event: Event) => {
const target = event.target as HTMLInputElement;
console.log("Searching for:", target.value);
});

Typing fetch responses​

interface Post {
id: number;
title: string;
body: string;
}

async function fetchPosts(): Promise<Post[]> {
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
const posts: Post[] = await response.json();
return posts;
}

fetchPosts().then(posts => {
posts.forEach(post => console.log(post.title));
});

The annotation tells TypeScript what you expect, but it does not validate the JSON at runtime. Validate untrusted API responses before relying on their shape in production code. For small shapes, a hand-written type guard is enough:

interface Post {
id: number;
title: string;
body: string;
}

function isPost(value: unknown): value is Post {
if (typeof value !== "object" || value === null) return false;

const post = value as Record<string, unknown>;
return typeof post.id === "number"
&& typeof post.title === "string"
&& typeof post.body === "string";
}

function isPostArray(value: unknown): value is Post[] {
return Array.isArray(value) && value.every(isPost);
}

async function fetchPosts(): Promise<Post[]> {
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
const data: unknown = await response.json();

if (!isPostArray(data)) {
throw new Error("API returned invalid posts");
}

return data;
}

For larger API contracts, schema libraries such as Zod or Valibot can define the runtime validation and TypeScript type from one schema.

Typing an object map​

const cache: Record<string, number> = {};

cache["a"] = 1;
cache["b"] = 2;

// Or using an index signature
interface Cache {
[key: string]: number;
}

Typing a class​

interface Printable {
toString(): string;
}

class User implements Printable {
constructor(
public readonly name: string,
public readonly email: string,
private age: number,
) {}

getAge(): number {
return this.age;
}

toString(): string {
return `${this.name} (${this.email})`;
}
}

const user = new User("Ada", "ada@example.com", 36);
console.log(user.toString()); // Ada (ada@example.com)
console.log(user.name); // Ada
// console.log(user.age); // Error: Property 'age' is private

TypeScript's public, private, protected, and readonly modifiers in the constructor automatically create and assign class properties - no separate field declarations needed.

Constructor parameter properties require a TypeScript transform. They work when you compile with tsc, but not in Node's strip-only TypeScript mode.

Migrating JavaScript to TypeScript​

You do not need to convert your entire project at once. TypeScript supports gradual migration:

Step 1: Add TypeScript to the project​

npm install --save-dev typescript
npx tsc --init

Step 2: Allow JavaScript files​

In tsconfig.json:

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

allowJs: true lets .js and .ts files coexist in the same project.

Step 3: Rename files one at a time​

Rename .js files to .ts and fix any type errors that appear. Start with the simplest files (utility functions, constants) and work toward the more complex ones.

Step 4: Enable checkJs (optional)​

Setting checkJs: true makes TypeScript check your .js files too - it will infer types and report errors even in plain JavaScript.

Step 5: Turn on strict mode​

Once all files are .ts, enable "strict": true for maximum type safety.

Running TypeScript in the browser​

TypeScript must be compiled to JavaScript before the browser can run it. Common approaches:

ToolHow it works
tscTypeScript compiler - compiles .ts to .js
ViteDev server and bundler with built-in TypeScript support
esbuildExtremely fast bundler that strips types
webpack + ts-loaderTraditional bundler with TypeScript loader

For new projects, Vite is the simplest choice:

npm create vite@latest my-app -- --template vanilla-ts
cd my-app
npm install
npm run dev

This gives you a TypeScript project with hot reloading, ready to develop in the browser.

Summary​

  • TypeScript is JavaScript with static types - JavaScript syntax is valid TypeScript syntax, but strict checking may still report type errors.
  • Type annotations (: string, : number) tell TypeScript what types to expect.
  • Type inference means you do not need to annotate everything - TypeScript is smart.
  • Interfaces describe object shapes; type aliases name any type.
  • Union types (string | number) model values that can be one of several types.
  • Generics (<T>) let you write reusable, type-safe code.
  • Utility types (Partial, Pick, Omit, Record) transform existing types.
  • strict: true in tsconfig.json catches the most bugs - always use it.
  • Migrate gradually: add TypeScript to an existing project one file at a time.
  • For a deeper sequence, continue with the full TypeScript beginners guide.

TypeScript is used by most modern JavaScript frameworks (Angular, Next.js, SvelteKit) and is the de facto standard for professional JavaScript development. The investment in learning it pays off immediately through fewer bugs and better tooling.

Next up: Error Handling - try/catch, custom errors, async error patterns, and best practices for failing gracefully.