TypeScript is JavaScript with type descriptions: the tsc compiler checks a program before it runs and catches misspelled field names, missing undefined checks and unhandled cases. The types themselves are erased at runtime, so plain JavaScript is what executes. To get started, install the typescript package, enable strict in tsconfig.json and describe your data with type and interface.
After this you will be able to
- Describe data with
typeandinterface, including optional andreadonlyfields - Narrow a union with
typeof, comparisons and a discriminant field, and check aswitchfor exhaustiveness withnever - Write a generic function with
extendsandkeyofconstraints - Accept JSON as
unknown, validate it with type predicates and set up atsconfig.jsonwithstrict
Verified with
TypeScript 7.0.2TypeScript 6.0.3TypeScript 5.9.3Node.js 24 LTS (24.18.0)
Checked against docs: October 11, 2026
Contents
Why types: catching the error before it runs
Take a small JavaScript module that processes coffee shop orders. It sums up the cart, applies a promo code discount and labels the order status. There are three bugs in it; try to spot them before reading the output.
export function subtotal(order) {
return order.items.reduce((sum, item) => sum + item.price * item.quanity, 0);
}
export function discount(order) {
return Math.round(subtotal(order) * order.promo.percent / 100);
}
export function statusLabel(status) {
if (status === "new") return "New";
if (status === "paid") return "Paid";
if (status === "shipped") return "Shipped";
}import { discount, statusLabel, subtotal } from "./orders.js";
const order = {
id: 1042,
status: "cancelled",
items: [{ sku: "CAP-M", name: "Cappuccino", price: 1600, qty: 2 }],
};
console.log(subtotal(order));
console.log(statusLabel(order.status));
console.log(discount(order));$ node js/main.js
NaN
undefined
…\js\orders.js:6
return Math.round(subtotal(order) * order.promo.percent / 100);
^
TypeError: Cannot read properties of undefined (reading 'percent')
at discount (…/js/orders.js:6:51)
at …
Node.js v24.18.0JavaScript stayed silent twice: the quanity typo produced NaN, and for the "cancelled" status the function returned undefined. Only the third bug, an order without a promo code, crashed the program, and only at runtime. In a real project, it's the customer who finds bugs like these.
The TypeScript documentation puts its goal this way: TypeScript is a static type checker for JavaScript programs, a tool that runs before your code runs and makes sure the program's types are correct. A type describes which values are allowed: a number, a string, an object with certain fields, or one of a listed set of options.
Create the project
You need Node.js 24 LTS. Install the compiler as a dev dependency so that everyone on the project uses the same version.
Add scripts
checkruns the type check without emitting files,startruns the program. Node.js 24 can run.tsfiles on its own by stripping the types.Set the checking rules
tsconfig.jsontells the compiler where the code lives and how strictly to check it. We'll go through it in detail in the last section.
mkdir orders-ts
cd orders-ts
npm init -y
npm install -D typescript
npx tsc -vnpx tsc -v prints Version 7.0.2.{
"name": "orders-ts",
"private": true,
"type": "module",
"scripts": {
"check": "tsc --noEmit",
"start": "node src/main.ts"
},
"devDependencies": {
"typescript": "^7.0.2"
}
}{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"target": "es2024",
"module": "nodenext",
"noEmit": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"rewriteRelativeImportExtensions": true
},
"include": ["src"]
}Now rename orders.js to src/orders.ts and describe what the functions take as input. The Order and OrderStatus types live in a separate file, src/types.ts; we'll cover it in the section on type and interface. The function bodies stay the same.
import type { Order, OrderStatus } from "./types.ts";
export function subtotal(order: Order): number {
return order.items.reduce((sum, item) => sum + item.price * item.quanity, 0);
}
export function discount(order: Order): number {
return Math.round(subtotal(order) * order.promo.percent / 100);
}
export function statusLabel(status: OrderStatus): string {
if (status === "new") return "New";
if (status === "paid") return "Paid";
if (status === "shipped") return "Shipped";
}$ npm run check
> check
> tsc --noEmit
src/orders.ts(4,68): error TS2339: Property 'quanity' does not exist on type 'OrderItem'.
src/orders.ts(8,39): error TS18048: 'order.promo' is possibly 'undefined'.
src/orders.ts(11,51): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.- TS2339:
OrderItemhas noquanityfield. The typo is visible straight away, with noNaNin the report. - TS18048:
promois optional, it may be missing, and you can't readpercentwithout checking first. - TS2366: the function promises to return a
string, but returns nothing for one of the statuses.
Basic types, type inference and annotations
A type annotation is written after a name with a colon: price: number. You don't need to write one everywhere, though. According to the documentation, TypeScript infers types wherever it can: a variable's type comes from its initial value. The Handbook explicitly advises beginners to use fewer annotations than they think they need.
string
- Values
- strings
- Example
const name = "Cappuccino"
number
- Values
- integers and decimals,
NaN - Example
const price = 1600
boolean
- Values
trueandfalse- Example
let paid = false
string[] or Array<string>
- Values
- an array of strings
- Example
const names = ["Cappuccino", "Cheesecake"]
null, undefined
- Values
- “no value”
- Example
promo?: Promo: the field may be missing
any
- Values
- anything, checking disabled
- Example
- avoid it; see the section on
unknown
let total = 0; // inferred type: number
total = "6540 ₸";
const names = ["Cappuccino", "Cheesecake"]; // string[]
const count = names.length; // number
// parameters need annotations; the return type (number) is inferred
function lineTotal(price: number, qty: number) {
return price * qty;
}
// without an annotation the parameter would be an implicit any, which strict forbids
function fee(order) {
return order.delivery.fee;
}$ npx tsc --noEmit
src/inference.ts(2,1): error TS2322: Type 'string' is not assignable to type 'number'.
src/inference.ts(13,14): error TS7006: Parameter 'order' implicitly has an 'any' type.Where annotations are needed: function parameters. The compiler can't guess what will be passed to fee(order), and without strict it would silently give the parameter the type any, switching checks off for it. The noImplicitAny flag, part of strict, turns that into error TS7006. A function's return type is usually inferred from return, so lineTotal doesn't need one.
type and interface: describing data
All of the module's entities are described in one file. An interface defines the shape of an object; a type gives a name to any type, including a union of options.
export type OrderStatus = "new" | "paid" | "shipped" | "cancelled";
export interface OrderItem {
readonly sku: string;
name: string;
price: number; // tenge per unit
qty: number;
}
export interface Promo {
code: string;
percent: number;
}
export type Delivery =
| { kind: "pickup" }
| { kind: "courier"; address: string; fee: number };
export interface Order {
readonly id: number;
customer: string;
status: OrderStatus;
items: OrderItem[];
promo?: Promo;
delivery: Delivery;
}promo?: Promois an optional field. When read, its type isPromo | undefined, so it needs a check before use.readonly idandreadonly skucan't be reassigned once the object is created. According to the Handbook,readonlydoesn't change runtime behaviour; it's a restriction at type-checking time only.OrderStatusis a union of string literals: the status can only be one of four values, not any string.Deliveryis a union of two object shapes with a sharedkindfield. We'll look at this kind of type in the next section.
import type { Order } from "./types.ts";
function renumber(order: Order) {
order.id = 1; // id is declared readonly
order.items[0].sku = "NEW"; // sku is readonly too, and item [0] may not exist
order.promo.percent = 50; // promo is optional
}$ npx tsc --noEmit
src/readonly.ts(4,9): error TS2540: Cannot assign to 'id' because it is a read-only property.
src/readonly.ts(5,3): error TS2532: Object is possibly 'undefined'.
src/readonly.ts(5,18): error TS2540: Cannot assign to 'sku' because it is a read-only property.
src/readonly.ts(6,3): error TS18048: 'order.promo' is possibly 'undefined'.The second line produced two errors at once. Besides readonly, the noUncheckedIndexedAccess flag kicked in: order.items[0] may not exist if the cart is empty, so its type is OrderItem | undefined. Note that readonly only protects the field itself. The documentation points out that the contents of an object held in a readonly field can still be changed.
Object shape
interface- yes
type- yes
Union of options (A | B)
interface- no
type- yes
Extending
interfaceinterface B extends Atype- intersection
A & B
Adding fields by declaring again
interface- yes (declaration merging)
type- no, a duplicate name is an error
The Handbook says that in most cases the choice is a matter of preference, and offers a heuristic: use interface until you need features from type. That's what we did: objects are interfaces, unions are declared with type.
Unions, literal types and narrowing
A union type describes a value that can be one of several types: Promo | undefined, "pickup" | "courier". According to the documentation, a union only allows operations that are valid for every one of its members. To work with a specific member, you narrow the type by checking in code which option you have. The Handbook calls this narrowing: refining a type to something more specific than what was declared.
import type { Delivery, Order, OrderStatus } from "./types.ts";
export function subtotal(order: Order): number {
return order.items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
export function discount(order: Order): number {
if (order.promo === undefined) {
return 0;
}
// here order.promo is already Promo, not Promo | undefined
return Math.round((subtotal(order) * order.promo.percent) / 100);
}
export function deliveryFee(delivery: Delivery): number {
switch (delivery.kind) {
case "pickup":
return 0;
case "courier":
return delivery.fee; // only courier delivery has a fee
}
}
export function orderTotal(order: Order): number {
return subtotal(order) - discount(order) + deliveryFee(order.delivery);
}
export function statusLabel(status: OrderStatus): string {
switch (status) {
case "new":
return "New";
case "paid":
return "Paid";
case "shipped":
return "Shipped";
case "cancelled":
return "Cancelled";
default: {
// reachable only if a new status is added to OrderStatus
const unhandled: never = status;
return unhandled;
}
}
}discount: afterif (order.promo === undefined) return 0, the compiler knows thatorder.promois aPromofrom then on. That's narrowing by comparison.deliveryFee: bothDeliveryoptions have akindfield with a literal type. The documentation calls this a discriminated union: checkingkindin aswitchnarrows the whole object, so the"courier"branch can accessfee.statusLabel: thedefaultbranch assigns the status to a variable of typenever, meaning “no values”. If every option is handled, nothing ever reaches it. If someone adds a new status toOrderStatus, the assignment becomes an error.
Let's test that last point: add a "refunded" status to OrderStatus and run the check without touching statusLabel.
$ npx tsc --noEmit
src/orders.ts(40,13): error TS2322: Type '"refunded"' is not assignable to type 'never'.The compiler pointed at exactly the place that needs updating. Without this check, the function would return undefined, as the original JavaScript did. This technique comes from the Handbook and is called exhaustiveness checking.
typeof
- Example
typeof value === "string"- What the compiler learns
- the value's primitive type
Comparison
- Example
order.promo === undefined- What the compiler learns
- whether the value is there
Discriminant field
- Example
switch (delivery.kind)- What the compiler learns
- the specific member of the union
The in operator
- Example
"total" in data- What the compiler learns
- the object has that field
Array.isArray, instanceof
- Example
error instanceof Error- What the compiler learns
- an array or a class instance
Type predicate
- Example
function isItem(v: unknown): v is OrderItem- What the compiler learns
- whatever your checking function guarantees
Generics: one function for many types
Some functions work the same way with data of any shape: take the first element, find a record by id, collect the values of one field. If you declare the parameter as any[], the function accepts anything but, as the Handbook explains, loses the type information: what comes out is any too. A generic solves this with a type parameter, a variable that holds a type rather than a value.
// T is a type parameter: whatever array comes in, an element of the same type comes out
export function first<T>(items: readonly T[]): T | undefined {
return items[0];
}
// T must have a numeric id field: this is a constraint
export function findById<T extends { id: number }>(
items: readonly T[],
id: number,
): T | undefined {
return items.find((item) => item.id === id);
}
// K can only be an existing key of T; the result is an array of that field's values
export function pluck<T, K extends keyof T>(items: readonly T[], key: K): T[K][] {
return items.map((item) => item[key]);
}first<T>:Tis filled in automatically at the call site. Forfirst(order.items)the compiler infersT = OrderItem, and the result isOrderItem | undefinedbecause the array may be empty.findById<T extends { id: number }>is a constraint: any array of objects with a numericidwill do, and you get back an object of the same type, not a stripped-down{ id: number }.pluck<T, K extends keyof T>:keyof Tmeans “the name of one ofT's fields”. The resultT[K][]is an array of that field's values:number[]for"price",string[]for"name".
import type { Order } from "./types.ts";
import { first, pluck } from "./utils.ts";
function report(order: Order) {
const prices = pluck(order.items, "price"); // number[]
const titles = pluck(order.items, "title"); // no such field
const top = first(order.items);
console.log(top.name); // first may return undefined
}$ npx tsc --noEmit
src/generic-error.ts(6,37): error TS2345: Argument of type '"title"' is not assignable to parameter of type 'keyof OrderItem'.
src/generic-error.ts(8,15): error TS18048: 'top' is possibly 'undefined'.According to the documentation, you usually don't pass the type parameter explicitly: the compiler infers it from the arguments. An explicit form such as first<OrderItem>(items) is for when inference fails. If a generic function is hard to read, take it as a signal to simplify: a type parameter belongs where it links the input to the output.
unknown vs any: typing JSON from the server
TypeScript only checks your code. It can't see data that arrives over the network, from localStorage or from the user. JSON.parse returns any, and that's dangerous: according to the Handbook, any disables type checking, and from then on every typo slips through silently. The safe alternative is unknown: the documentation describes it as a type for any value that you can't do anything with until you prove what it is.
const fromAny: any = JSON.parse(`{ "total": 6540 }`);
fromAny.totl.toFixed(); // the compiler stays silent; this crashes at runtime
const fromUnknown: unknown = JSON.parse(`{ "total": 6540 }`);
fromUnknown.total; // error: first prove it's an object with a total field
if (typeof fromUnknown === "object" && fromUnknown !== null && "total" in fromUnknown) {
console.log(fromUnknown.total); // the field's type here is unknown; check it too
}any lets the totl typo through, unknown doesn't.$ npx tsc --noEmit
src/unknown.ts(5,1): error TS18046: 'fromUnknown' is of type 'unknown'.To turn unknown into an Order, you need runtime checks. Functions with a value is OrderItem return type are type predicates: when such a function returns true, the compiler narrows the value to OrderItem.
import type { Delivery, Order, OrderItem, OrderStatus, Promo } from "./types.ts";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isStatus(value: unknown): value is OrderStatus {
return value === "new" || value === "paid" || value === "shipped" || value === "cancelled";
}
function isItem(value: unknown): value is OrderItem {
return (
isRecord(value) &&
typeof value.sku === "string" &&
typeof value.name === "string" &&
typeof value.price === "number" &&
typeof value.qty === "number"
);
}
function isPromo(value: unknown): value is Promo {
return isRecord(value) && typeof value.code === "string" && typeof value.percent === "number";
}
function isDelivery(value: unknown): value is Delivery {
if (!isRecord(value)) return false;
if (value.kind === "pickup") return true;
return (
value.kind === "courier" &&
typeof value.address === "string" &&
typeof value.fee === "number"
);
}
export function parseOrder(json: string): Order {
const data: unknown = JSON.parse(json);
if (
!isRecord(data) ||
typeof data.id !== "number" ||
typeof data.customer !== "string" ||
!isStatus(data.status) ||
!Array.isArray(data.items) ||
!data.items.every(isItem) ||
!isDelivery(data.delivery)
) {
throw new Error("The server returned an order in an unexpected format");
}
const order: Order = {
id: data.id,
customer: data.customer,
status: data.status,
items: data.items,
delivery: data.delivery,
};
if (isPromo(data.promo)) {
order.promo = data.promo;
}
return order;
}import { orderTotal, statusLabel } from "./orders.ts";
import { parseOrder } from "./parse.ts";
import { findById, first, pluck } from "./utils.ts";
// what a server response might look like; for now it's just a string
const response = `{
"id": 1042,
"customer": "Aliya",
"status": "paid",
"items": [
{ "sku": "CAP-M", "name": "Cappuccino", "price": 1600, "qty": 2 },
{ "sku": "CHS-1", "name": "Cheesecake", "price": 2400, "qty": 1 }
],
"promo": { "code": "AUTUMN", "percent": 10 },
"delivery": { "kind": "courier", "address": "10 Abay Ave", "fee": 1500 }
}`;
const order = parseOrder(response);
console.log(`Order ${order.id}: ${statusLabel(order.status)}`);
console.log(pluck(order.items, "name").join(", "));
console.log(`Total due: ${orderTotal(order)} ₸`);
const top = first(order.items); // type: OrderItem | undefined
console.log(top?.name ?? "cart is empty");
const found = findById([order], 1042); // type: Order | undefined
console.log(found ? "order found" : "no such order");
try {
parseOrder(`{ "id": "1042" }`);
} catch (error) {
console.log(error instanceof Error ? error.message : error);
}$ npm run check && npm start
> check
> tsc --noEmit
> start
> node src/main.ts
Order 1042: Paid
Cappuccino, Cheesecake
Total due: 6540 ₸
Cappuccino
order found
The server returned an order in an unexpected formatAn engineering trade-off: hand-written checks like those in parse.ts are fine for a tutorial and a couple of endpoints. Projects with dozens of API responses usually use a schema library (Zod, for example), where the type and the check are derived from a single description and can't drift apart.
tsconfig and strict: a minimal setup
tsconfig.json stores compiler settings so you don't have to pass them as flags. Ours is short, and every line has a purpose.
strict: true
- What it does
- Turns on the family of strict checks, including
noImplicitAnyandstrictNullChecks. The documentation recommendsstrictNullCheckswherever it's practical.
noUncheckedIndexedAccess
- What it does
- Adds
undefinedto the element type on index access:items[0]isOrderItem | undefined. Not part ofstrict.
target: es2024
- What it does
- Which JavaScript version the output targets and which built-in APIs are assumed to exist.
module: nodenext
- What it does
- Node.js module rules: honours
"type": "module"and requires extensions in import paths.
noEmit
- What it does
- Check only, don't write
.jsfiles: Node.js runs the code itself.
erasableSyntaxOnly
- What it does
- Forbids TypeScript constructs that can't simply be stripped (
enum,namespacewith code, parameter properties): Node.js won't run them.
verbatimModuleSyntax
- What it does
- Requires
import typefor type-only imports so that stripping types leaves no stray imports behind.
rewriteRelativeImportExtensions
- What it does
- Allows imports with a
.tsextension; Node.js requires the file extension.
What to know about versions. TypeScript 7.0 is a new compiler rewritten in Go: according to the team's announcement, full builds are roughly 8–12 times faster, and type checking is compatible with 6.0. However, 7.0 doesn't ship a programmatic API yet, so tools that embed TypeScript (typescript-eslint, for example) work through a 6.0 compatibility package, and templates such as Vite's still install TypeScript 6.0. For this lesson it makes no difference: the syntax and the error messages are the same.
Node.js 24 runs .ts files directly: according to the Node.js documentation, type stripping has been stable since 24.12 and is on by default. But Node.js doesn't type-check and doesn't read tsconfig.json, so running npm run check before starting is a must. In browser projects a bundler such as Vite plays the same role, and tsc does the checking.
Practice and next steps
To sum up: types describe data, narrowing helps the compiler work out which option it has, generics keep types intact in reusable functions, and unknown plus runtime checks guard the boundary with the outside world. Next on the React track: components and props, where the same interfaces describe what data each piece of the UI accepts.
How this was verified6 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.