How to buildOctober 11, 202611 min read
ReactStep 1 of 8

TypeScript from scratch: types, interfaces, generics

In short

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.

TutorialLevel: BeginnerTypeScriptPractice: ~45–90 min

After this you will be able to

  • Describe data with type and interface, including optional and readonly fields
  • Narrow a union with typeof, comparisons and a discriminant field, and check a switch for exhaustiveness with never
  • Write a generic function with extends and keyof constraints
  • Accept JSON as unknown, validate it with type predicates and set up a tsconfig.json with strict

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
  1. 01Why types: catching the error before it runs
  2. 02Basic types, type inference and annotations
  3. 03type and interface: describing data
  4. 04Unions, literal types and narrowing
  5. 05Generics: one function for many types
  6. 06unknown vs any: typing JSON from the server
  7. 07tsconfig and strict: a minimal setup
  8. 08Practice and next steps

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.

js/orders.js
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";
}
js/main.js
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));
terminal
$ 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.0
Path shortened, stack frames omitted. The first two bugs didn't stop the program; the third one did.

JavaScript 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.

  1. 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.

  2. Add scripts

    check runs the type check without emitting files, start runs the program. Node.js 24 can run .ts files on its own by stripping the types.

  3. Set the checking rules

    tsconfig.json tells the compiler where the code lives and how strictly to check it. We'll go through it in detail in the last section.

bash
mkdir orders-ts
cd orders-ts
npm init -y
npm install -D typescript
npx tsc -v
At the time of checking, npx tsc -v prints Version 7.0.2.
package.json
{
  "name": "orders-ts",
  "private": true,
  "type": "module",
  "scripts": {
    "check": "tsc --noEmit",
    "start": "node src/main.ts"
  },
  "devDependencies": {
    "typescript": "^7.0.2"
  }
}
"type": "module" enables ES modules: import and export.
tsconfig.json
{
  "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.

src/orders.ts
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";
}
First version: the same code as orders.js, plus parameter and return types.
terminal
$ 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: OrderItem has no quanity field. The typo is visible straight away, with no NaN in the report.
  • TS18048: promo is optional, it may be missing, and you can't read percent without 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
true and false
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
src/inference.ts
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;
}
A practice file with deliberate errors: put it in src, run the check, then delete it.
terminal
$ 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.

src/types.ts
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?: Promo is an optional field. When read, its type is Promo | undefined, so it needs a check before use.
  • readonly id and readonly sku can't be reassigned once the object is created. According to the Handbook, readonly doesn't change runtime behaviour; it's a restriction at type-checking time only.
  • OrderStatus is a union of string literals: the status can only be one of four values, not any string.
  • Delivery is a union of two object shapes with a shared kind field. We'll look at this kind of type in the next section.
src/readonly.ts
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
}
A practice file with deliberate errors.
terminal
$ 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

interface
interface B extends A
type
intersection A & B

Adding fields by declaring again

interface
yes (declaration merging)
type
no, a duplicate name is an error
Differences according to the Handbook (Everyday Types).

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.

src/orders.ts
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;
    }
  }
}
The fixed version of the module.
  • discount: after if (order.promo === undefined) return 0, the compiler knows that order.promo is a Promo from then on. That's narrowing by comparison.
  • deliveryFee: both Delivery options have a kind field with a literal type. The documentation calls this a discriminated union: checking kind in a switch narrows the whole object, so the "courier" branch can access fee.
  • statusLabel: the default branch assigns the status to a variable of type never, meaning “no values”. If every option is handled, nothing ever reaches it. If someone adds a new status to OrderStatus, the assignment becomes an error.

Let's test that last point: add a "refunded" status to OrderStatus and run the check without touching statusLabel.

terminal
$ 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.

src/utils.ts
// 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>: T is filled in automatically at the call site. For first(order.items) the compiler infers T = OrderItem, and the result is OrderItem | undefined because the array may be empty.
  • findById<T extends { id: number }> is a constraint: any array of objects with a numeric id will do, and you get back an object of the same type, not a stripped-down { id: number }.
  • pluck<T, K extends keyof T>: keyof T means “the name of one of T's fields”. The result T[K][] is an array of that field's values: number[] for "price", string[] for "name".
src/generic-error.ts
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
}
A practice file with deliberate errors.
terminal
$ 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.

src/unknown.ts
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
}
A practice file: any lets the totl typo through, unknown doesn't.
terminal
$ 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.

src/parse.ts
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;
}
src/main.ts
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);
}
terminal
$ 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 format
The type check passed and the program ran. The last line shows a server response with a string id being rejected.

An 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 noImplicitAny and strictNullChecks. The documentation recommends strictNullChecks wherever it's practical.

noUncheckedIndexedAccess

What it does
Adds undefined to the element type on index access: items[0] is OrderItem | undefined. Not part of strict.

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 .js files: Node.js runs the code itself.

erasableSyntaxOnly

What it does
Forbids TypeScript constructs that can't simply be stripped (enum, namespace with code, parameter properties): Node.js won't run them.

verbatimModuleSyntax

What it does
Requires import type for type-only imports so that stripping types leaves no stray imports behind.

rewriteRelativeImportExtensions

What it does
Allows imports with a .ts extension; 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.