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. Every valid JavaScript file is also valid TypeScript -- you can adopt it gradually.

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

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.

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 name: 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 name = "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 name = 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 [name, 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; // 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")!;

This is equivalent to as HTMLElement but shorter. 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
noImplicitAnyVariables and parameters must have explicit types (no implicit 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));
});

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.

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 -- every .js file is valid .ts.
  • 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.

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.