JavaScript runs the web, but it has a famous flaw: it lets you do almost anything and tells you nothing until the code is running in front of a customer. undefined is not a function, Cannot read properties of undefined, a string quietly added to a number — every JavaScript developer has met them. TypeScript is the fix. It is JavaScript plus a type system that checks your code before it runs, and then gets out of the way by compiling down to plain JavaScript.
Microsoft released TypeScript in 2012, led by Anders Hejlsberg — the same designer behind Turbo Pascal, Delphi, and C#. Today it is the default choice for serious front-end and Node.js work, and most new React, Angular, and Node projects start in it. If you already write JavaScript (see our JavaScript deep dive or the React guide), you already know 90% of TypeScript.
For years the TypeScript compiler was itself written in TypeScript, running on Node. That worked, but large projects spent minutes type-checking. With TypeScript 7.0, the compiler has been rewritten as a native program in Go (the effort was nicknamed Corsa). Microsoft reports full builds on large codebases running roughly 8x to 12x faster. The VS Code codebase is the headline benchmark: about 125 seconds down to about 11.
What that means for you:
npm install -D typescript and run tsc.Everything below applies to both 6.x and 7.x.
function greet(name: string): string {
return `Hello, ${name}!`;
}
console.log(greet("World"));
The : string annotations are the only new part. Now watch what the compiler does with a mistake:
greet(42);
// error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
That error appears in your editor as you type, not in production at 2 a.m.
npm init -y
npm install -D typescript
npx tsc --init # creates tsconfig.json
npx tsc # compile every .ts file per tsconfig
npx tsc --noEmit # type-check only, produce no output
You can also run a .ts file directly. Recent versions of Node.js can execute TypeScript by simply stripping the type annotations, no build step needed:
node hello.ts
This works only for "erasable" syntax — things that disappear when you delete the types. A few older features (such as enum and constructor parameter properties) generate real code and are not supported that way. Turn on "erasableSyntaxOnly": true in tsconfig.json and the compiler will warn you if you use them. Bun and Deno also run TypeScript natively, and the tsx package does the same on top of Node.
A solid starter tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"]
}
Always turn on "strict": true. It is the difference between a real safety net and a decorative one.
let name: string = "Ada";
let age: number = 36; // all numbers are 'number' (no int/float split)
let active: boolean = true;
let scores: number[] = [90, 85, 77];
let pair: [string, number] = ["Ada", 36]; // a tuple: fixed length and types
// Usually you can leave the annotation off. TypeScript infers it:
let city = "Provo"; // inferred as string
city = 5; // error: number is not assignable to string
Let inference do the work. Annotate function parameters and return types at your public boundaries; let the compiler infer local variables.
You describe the shape of an object, and the compiler enforces it everywhere:
interface User {
id: number;
name: string;
email?: string; // optional
readonly created: Date; // cannot be reassigned
}
const u: User = { id: 1, name: "Ada", created: new Date() };
u.created = new Date(); // error: read-only property
type does the same job and can also name unions, tuples, and more:
type ID = number | string;
type Point = { x: number; y: number };
Rule of thumb: use interface for object shapes you may extend, and type for everything else. Either works for most day-to-day code.
This is where TypeScript earns its keep. A value can be one of several types, and the compiler tracks which one it is as your code branches:
function describe(id: number | string): string {
if (typeof id === "string") {
return id.toUpperCase(); // here id is a string
}
return id.toFixed(2); // here id is a number
}
Discriminated unions model "one of these cases" cleanly, and the compiler can prove you handled them all:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "square": return s.side ** 2;
}
}
Add a third shape later and every switch that forgets it becomes a compile error. That is a refactoring superpower.
With strict on, null and undefined are not silently allowed everywhere. TypeScript forces you to deal with them:
function firstChar(s: string | undefined): string {
return s[0]; // error: 's' is possibly 'undefined'
}
function firstCharSafe(s: string | undefined): string {
return s?.[0] ?? ""; // optional chaining plus a default
}
?. (optional chaining) and ?? (nullish coalescing) are standard JavaScript now, and they pair perfectly with the type checker. Avoid the non-null assertion s! — it just tells the compiler to stop checking, which defeats the purpose.
function add(a: number, b: number = 0): number {
return a + b;
}
const double = (n: number): number => n * 2;
// A function type
type Handler = (event: string, payload?: unknown) => void;
// Rest parameters
function sum(...nums: number[]): number {
return nums.reduce((a, b) => a + b, 0);
}
Generics let you write one function that works for many types while staying fully type-safe:
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([1, 2, 3]); // number | undefined
const s = first(["a", "b"]); // string | undefined
interface ApiResult<T> {
ok: boolean;
data: T;
}
async function getJson<T>(url: string): Promise<ApiResult<T>> {
const res = await fetch(url);
return { ok: res.ok, data: (await res.json()) as T };
}
You will meet generics constantly in libraries (Array<T>, Promise<T>, Map<K, V>), so it pays to be comfortable reading them.
TypeScript ships helpers that transform existing types, so you don't repeat yourself:
interface User { id: number; name: string; email: string }
type NewUser = Omit<User, "id">; // everything but id
type UserPreview = Pick<User, "id" | "name">; // just these fields
type UserPatch = Partial<User>; // every field optional
type FrozenUser = Readonly<User>; // every field read-only
type UserMap = Record<number, User>; // object keyed by id
ReturnType<typeof fn>, Parameters<typeof fn>, and Awaited<Promise<T>> are also worth knowing.
class Account {
private balance = 0;
constructor(public readonly owner: string) {}
deposit(amount: number): void {
if (amount <= 0) throw new Error("Amount must be positive");
this.balance += amount;
}
get total(): number {
return this.balance;
}
}
const a = new Account("Ada");
a.deposit(50);
console.log(a.total); // 50
a.balance; // error: 'balance' is private
Note that private is checked only at compile time. If you need true runtime privacy, use JavaScript's #balance syntax instead.
interface Repo { name: string; stargazers_count: number }
async function topRepos(user: string): Promise<Repo[]> {
const res = await fetch(`https://api.github.com/users/${user}/repos`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const repos = (await res.json()) as Repo[];
return repos.sort((a, b) => b.stargazers_count - a.stargazers_count);
}
TypeScript types exist only while compiling. The as Repo[] above is a promise you make to the compiler, not a check. If the server returns something different, TypeScript will not notice, and your code will fail at runtime anyway.
Anything that crosses a trust boundary — an API response, a form submission, JSON.parse, a config file — should be validated at runtime. The popular approach is a schema library such as Zod, which gives you a runtime validator and the matching TypeScript type from one definition:
import { z } from "zod";
const RepoSchema = z.object({ name: z.string(), stargazers_count: z.number() });
type Repo = z.infer<typeof RepoSchema>;
const repo = RepoSchema.parse(await res.json()); // throws if the shape is wrong
any turns the type checker off for that value. It spreads like a virus. Avoid it.unknown means "I don't know yet," and you must narrow it before using it. It is the safe replacement for any.function parse(input: unknown): string {
if (typeof input === "string") return input;
throw new Error("Expected a string");
}
Most npm packages ship their own types. For older ones, the community publishes them under @types:
npm install express
npm install -D @types/express @types/node
TypeScript and JavaScript can coexist in one project, so you can migrate a codebase file by file: rename .js to .ts, fix the errors, repeat. Set "allowJs": true in tsconfig.json while you transition.
| You know... | In TypeScript... |
|---|---|
| JavaScript | The same, plus : type annotations and interface/type |
| Python type hints | Similar idea, but TypeScript is checked by the compiler on every build and is far more expressive |
| Java / C# | Structural, not nominal: two types with the same shape are compatible without declaring a relationship |
| Rust / Swift | Union types and narrowing play the role of enums with exhaustive matching |
| PHP | Think of declare(strict_types=1) taken much further, including for arrays and objects |
as is a cast, not a conversion. "5" as number is an error, and x as unknown as number just hides a bug.enum is the odd one out. It generates runtime code; many teams prefer a union of string literals (type Mode = "dev" | "prod").undefined. Turn on noUncheckedIndexedAccess so arr[5] is typed T | undefined.Shines: large codebases, teams, APIs shared between front end and back end, long-lived projects, and refactoring. Editor autocomplete and rename-everywhere alone are worth the price of admission.
Less compelling: tiny throwaway scripts, or places where you need a build step you otherwise wouldn't have. (Even there, running .ts files directly in Node or Bun removes most of that friction.)
npm install -D typescript in a new folder.npx tsc --init and set "strict": true.interface and a discriminated union..js file to .ts and fix what the compiler finds — you will probably find a real bug.The best way to learn TypeScript is to let the compiler argue with you. Every error it raises is a bug you didn't ship.
Tell us what you learned from this article, or what you liked about it. We read every comment.
Know someone who'd love it too?