Skip to lesson

learningtypescript.org / basics / 05-data-structures · lesson 5 of 25

TL;DR

Learn typed arrays, tuples, and objects in TypeScript. Build type-safe data structures with interfaces, readonly, and generics.

Key concepts

  • TypeScript arrays
  • TypeScript tuples
  • typed data structures
  • TypeScript objects

Data Structures in TypeScript

In this lesson, we'll explore TypeScript's data structures, including arrays, objects, tuples, and more. These structures help you organize and manage data effectively in your programs.

What You'll Learn

By the end of this lesson you will build the activity log the rest of the course works with: an array of typed records, and three functions over it — one that projects a field out of every record, one that totals a number across them, and one that counts them by category — each proved correct by checks that fail until it is right.

This is a milestone lesson. Completing it marks the domain model in your learning tracker, and the tracker needs it: the capstone project is a learning tracker of your own, and the very first thing it needs is a description of the thing being tracked. The typed activity array you build here is literally the data the capstone's later layers read, report on and validate. You arrive here able to write typed functions and narrow a union inside a branch (Functions, Control Flow); what is new is holding many values at once and describing the shape of each.

The Model: What Gets Widened, and When

Before the tour of collection types, one rule predicts most of what follows, and it is the same rule that decided const versus let back in Variables and Types: the compiler infers the widest type that stays true for how the value can be used, and you narrow that deliberately when you want the precision.

For a collection, "how it can be used" means the contents can change. So [10, 20] is inferred as number[] — an array of any length holding any numbers — and not as the two-element tuple [10, 20], because nothing stops you pushing a third element or reassigning the first. If you want the fixed length and the per-position types, you have to say so: annotate [number, number], or write as const, which freezes the contents and lets the literal types survive. The same reasoning runs through objects: field values widen ({ name: "Alice" } gives name: string, not name: "Alice") unless as const tells the compiler nothing will change.

That one rule lets you predict cases this lesson does not show you. Whenever you find yourself surprised that a type is less precise than the value looked, ask what the compiler had to leave room for.

Arrays

Arrays store ordered collections of values of the same type:

Basic Arrays

// Array of numbers
const numbers: number[] = [1, 2, 3, 4, 5];
console.log(`Numbers: ${numbers}`);

// Alternative syntax
const fruits: Array<string> = ["apple", "banana", "orange"];
console.log(`Fruits: ${fruits}`);

// Accessing elements
console.log(`First fruit: ${fruits[0]}`);
console.log(`Last fruit: ${fruits[fruits.length - 1]}`);

Array Methods

const numbers: number[] = [1, 2, 3, 4, 5];

// Push: Add to end
numbers.push(6);
console.log(`After push: ${numbers}`);

// Pop: Remove from end
const last = numbers.pop();
console.log(`Popped: ${last}, Array: ${numbers}`);

// Shift: Remove from beginning
const first = numbers.shift();
console.log(`Shifted: ${first}, Array: ${numbers}`);

// Unshift: Add to beginning
numbers.unshift(0);
console.log(`After unshift: ${numbers}`);

Array Iteration

const fruits: string[] = ["apple", "banana", "orange"];

// forEach
console.log("Using forEach:");
fruits.forEach((fruit, index) => {
  console.log(`${index}: ${fruit}`);
});

// map
const upperFruits = fruits.map(fruit => fruit.toUpperCase());
console.log(`Uppercase: ${upperFruits}`);

// filter
const longFruits = fruits.filter(fruit => fruit.length > 5);
console.log(`Long fruits: ${longFruits}`);

// reduce
const numbers: number[] = [1, 2, 3, 4, 5];
const sum = numbers.reduce((total, num) => total + num, 0);
console.log(`Sum: ${sum}`);

Objects

Objects store key-value pairs and can represent complex data:

Basic Objects

// Object with type annotation
const person: { name: string; age: number; city: string } = {
  name: "Alice",
  age: 30,
  city: "New York"
};

console.log(`Name: ${person.name}`);
console.log(`Age: ${person.age}`);
console.log(`City: ${person.city}`);

Type Aliases

// Define a reusable type
type Person = {
  name: string;
  age: number;
  email?: string; // Optional property
};

const alice: Person = {
  name: "Alice",
  age: 30,
  email: "alice@example.com"
};

const bob: Person = {
  name: "Bob",
  age: 25
  // email is optional
};

console.log(`Alice: ${alice.name}, ${alice.email}`);
console.log(`Bob: ${bob.name}`);

Interfaces

An interface gives a name to an object's shape so you can annotate with the name instead of repeating the fields. This is a first look at it, used here because a collection of records needs a way to say what one record is — Interfaces and Types covers interfaces properly, including how they differ from type aliases, how they extend one another, and when to reach for each. Use it for now; you'll build on it there:

// Interface for object shape
interface Product {
  id: number;
  name: string;
  price: number;
  inStock: boolean;
}

const laptop: Product = {
  id: 1,
  name: "Laptop",
  price: 999.99,
  inStock: true
};

console.log(`Product: ${laptop.name}, Price: $${laptop.price}`);

Nested Objects

interface Address {
  street: string;
  city: string;
  zipCode: string;
}

interface User {
  name: string;
  address: Address;
}

const user: User = {
  name: "Alice",
  address: {
    street: "123 Main St",
    city: "New York",
    zipCode: "10001"
  }
};

console.log(`${user.name} lives at ${user.address.street}, ${user.address.city}`);

Tuples

Tuples are fixed-length arrays with specific types for each position:

A tuple's whole value is that the positions mean different things, and the compiler enforces the order. Get the positions the wrong way round and the program is rejected before it runs. This fence is marked no-run because its whole point is to fail to compile:

const person: [string, number] = ["Alice", 30];
const swapped: [string, number] = [30, "Alice"];
console.log(person, swapped);

Checked with tsc --strict, that reports both positions:

error TS2322: Type 'number' is not assignable to type 'string'.
error TS2322: Type 'string' is not assignable to type 'number'.

The runtime shadow is that nothing at all would go wrong at the moment of assignment — a JavaScript array happily holds a number then a string. The failure would appear later, wherever something expected person[1] to be a number and tried arithmetic on the word "Alice".

// Tuple: [string, number]
const person: [string, number] = ["Alice", 30];

console.log(`Name: ${person[0]}, Age: ${person[1]}`);

// Tuple with multiple types
const employee: [number, string, boolean] = [1, "Bob", true];
console.log(`ID: ${employee[0]}, Name: ${employee[1]}, Active: ${employee[2]}`);

// Destructuring tuples
const [personName, personAge] = person;
console.log(`Destructured - Name: ${personName}, Age: ${personAge}`);

Predict

A tuple like [number, number] is a fixed-length, position-typed array — but only when you say so. With NO annotation, what type does the compiler infer for point here?

const point = [10, 20];
Continue learning

Enums

Enums define a set of named constants. This is a preview — Enums and Constants covers them in full, including the important question of when a union of string literals is the better tool and what each one costs at run time. Read it for now; you'll build it later:

// Numeric enum
enum Direction {
  North,
  South,
  East,
  West
}

const currentDirection: Direction = Direction.North;
console.log(`Direction: ${currentDirection}`); // Prints: 0

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

const favoriteColor: Color = Color.Blue;
console.log(`Favorite color: ${favoriteColor}`); // Prints: BLUE

Maps

Maps store key-value pairs with any type as keys:

// Create a Map
const scores = new Map<string, number>();

// Add entries
scores.set("Alice", 95);
scores.set("Bob", 87);
scores.set("Charlie", 92);

// Get values
console.log(`Alice's score: ${scores.get("Alice")}`);

// Check if key exists
console.log(`Has Bob? ${scores.has("Bob")}`);

// Iterate over entries
console.log("All scores:");
scores.forEach((score, name) => {
  console.log(`${name}: ${score}`);
});

Sets

Sets store unique values. The Map section above was fully worked for you; this one is not. One line is left as a TODO, and the fence runs and prints a wrong answer until you supply it — the method you need is the one the Map already showed you, applied to a value instead of a key.

// Create a Set
const uniqueNumbers = new Set<number>();

// Add values
uniqueNumbers.add(1);
uniqueNumbers.add(2);
uniqueNumbers.add(3);
uniqueNumbers.add(2); // Duplicate, won't be added

console.log(`Set size: ${uniqueNumbers.size}`);

// TODO: the Map above answered "is this key present?" with scores.has("Bob").
// A Set asks the same question about a VALUE. Replace the false below with the
// call that asks whether 2 is in the set, then run it again.
const hasTwo: boolean = false;
console.log(`Has 2? ${hasTwo}`);

// Iterate over values
console.log("Values:");
uniqueNumbers.forEach(num => {
  console.log(num);
});

Try It Yourself

Practice working with data structures in the playground below:

// Arrays
const numbers: number[] = [5, 10, 15, 20, 25];

console.log("Original array:", numbers);

// Array methods
const doubled = numbers.map(n => n * 2);
console.log("Doubled:", doubled);

const evenNumbers = numbers.filter(n => n % 2 === 0);
console.log("Even numbers:", evenNumbers);

const sum = numbers.reduce((total, n) => total + n, 0);
console.log("Sum:", sum);

// Objects
interface Book {
  title: string;
  author: string;
  year: number;
  pages: number;
}

const book: Book = {
  title: "TypeScript Handbook",
  author: "TypeScript Team",
  year: 2024,
  pages: 300
};

console.log(`\nBook: ${book.title} by ${book.author}`);
console.log(`Published: ${book.year}, Pages: ${book.pages}`);

// Tuples
const coordinate: [number, number] = [10, 20];
console.log(`\nCoordinate: (${coordinate[0]}, ${coordinate[1]})`);

// Array of objects
const students: { name: string; grade: number }[] = [
  { name: "Alice", grade: 95 },
  { name: "Bob", grade: 87 },
  { name: "Charlie", grade: 92 }
];

console.log("\nStudents:");
students.forEach(student => {
  console.log(`${student.name}: ${student.grade}`);
});

// Find highest grade
const highest = students.reduce((max, student) =>
  student.grade > max.grade ? student : max
);
console.log(`Highest grade: ${highest.name} with ${highest.grade}`);

// Map
const phoneBook = new Map<string, string>();
phoneBook.set("Alice", "555-1234");
phoneBook.set("Bob", "555-5678");

console.log("\nPhone book:");
phoneBook.forEach((number, name) => {
  console.log(`${name}: ${number}`);
});

// Set
const tags = new Set<string>();
tags.add("typescript");
tags.add("javascript");
tags.add("programming");
tags.add("typescript"); // Duplicate, won't be added

console.log(`\nUnique tags (${tags.size}):`);
tags.forEach(tag => {
  console.log(`- ${tag}`);
});

Try these exercises:

  1. Create an array of numbers and find the average
  2. Create an interface for a Car with properties like make, model, and year
  3. Use map() to convert an array of Celsius temperatures to Fahrenheit
  4. Create a tuple representing RGB color values
  5. Use a Map to store and retrieve shopping cart items
  6. Create a Set to remove duplicates from an array

Recall

Without scrolling up: array methods like map and filter take a function as their argument — for example fruits.map(fruit => fruit.toUpperCase()). What is that fruit => fruit.toUpperCase() piece, in the terms Functions used?

Continue learning

Reading about arrays and objects is not the same as building with them. This is a build task: a small program that reports its own pass/fail. You are given a learning log — an array of typed activity records — and three empty, fully typed functions to finish. Run it and it fails immediately, telling you which function is still missing. Implement each one until every check passes and it prints All checks passed.

The three functions reuse exactly what this lesson taught: looping over an array of objects typed by an interface, reading a field with dot notation, aggregating with a running total, and building up a Record as you go. Record<string, number> is a built-in type meaning "an object whose keys are strings and whose values are numbers" — enough to use here, and the family of built-in types it belongs to is the subject of Utility Types. The starter already has the data, the stubs, and the checks — you write only the body of each function. Nothing above spells out all three answers, so you will have to assemble them yourself.

One of the three has a trap in it that is worth meeting before you hit it, because it produces a wrong answer rather than an error.

Debug

This counts how many activities there are of each kind, and the answer should be two lessons, two quizzes and one practice. It runs, nothing is annotated wrongly, and every single count comes out as a value that is not a number at all. Commit a hypothesis about what the counter holds the first time a new kind is seen, then fix it so the counts are right.

import assert from "node:assert";

const kinds: string[] = ["lesson", "quiz", "lesson", "practice", "quiz"];

const counts: Record<string, number> = {};
for (const kind of kinds) {
counts[kind] = counts[kind] + 1;
}

assert.deepStrictEqual(
counts,
{ lesson: 2, quiz: 2, practice: 1 },
"each kind should be counted, got " + JSON.stringify(counts),
);
console.log("counts:", counts);

Expected output:

counts: { lesson: 2, quiz: 2, practice: 1 }
Continue learning

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

import assert from "node:assert";

// The data: a learning log — an array of typed activity records. Do NOT change this array.
interface Activity {
title: string;
kind: string;
minutes: number;
}

const activities: Activity[] = [
{ title: "Intro to types", kind: "lesson", minutes: 25 },
{ title: "Union quiz", kind: "quiz", minutes: 10 },
{ title: "Narrowing drills", kind: "practice", minutes: 20 },
{ title: "Interfaces deep dive", kind: "lesson", minutes: 30 },
{ title: "Generics quiz", kind: "quiz", minutes: 15 },
];

// TODO 1: return a NEW array of just the titles, in order.
//   titles(activities) -> ["Intro to types", "Union quiz", "Narrowing drills", "Interfaces deep dive", "Generics quiz"]
function titles(activities: Activity[]): string[] | undefined {
return undefined; // replace undefined with your code
}

// TODO 2: return the total minutes across all activities as a number.
//   totalMinutes(activities) -> 100
function totalMinutes(activities: Activity[]): number | undefined {
return undefined; // replace undefined with your code
}

// TODO 3: return an object counting how many activities of each kind there are.
//   countByKind(activities) -> { lesson: 2, quiz: 2, practice: 1 }
function countByKind(activities: Activity[]): Record<string, number> | undefined {
return undefined; // replace undefined with your code
}

// --- Build checks: these must all pass. Do not edit below this line. ---
assert.deepStrictEqual(
titles(activities),
[
	"Intro to types",
	"Union quiz",
	"Narrowing drills",
	"Interfaces deep dive",
	"Generics quiz",
],
"titles(activities) should return an array of every activity's title, in order",
);
assert.strictEqual(
totalMinutes(activities),
100,
"totalMinutes(activities) should return the sum of every activity's minutes (100)",
);
assert.deepStrictEqual(
countByKind(activities),
{ lesson: 2, quiz: 2, practice: 1 },
"countByKind(activities) should map each kind to how many activities have it",
);

console.log("All checks passed.");
console.log("Titles:", titles(activities)?.join(", "));
console.log("Total minutes:", totalMinutes(activities));
console.log("By kind:", countByKind(activities));

Expected output:

All checks passed.
Titles: Intro to types, Union quiz, Narrowing drills, Interfaces deep dive, Generics quiz
Total minutes: 100
By kind: { lesson: 2, quiz: 2, practice: 1 }
Continue learning

Once it passes, try two variations and predict each before running:

  1. Push the whole activity. In titles, push activity instead of activity.title. Predict the shape of what titles(activities) returns before running. You get an array of the full activity objects, not their titles, so the deepStrictEqual against ["Intro to types", ...] fails — a reminder that building a projection means pulling out the one field, not the whole record.
  2. Drop the first-seen guard. In countByKind, count with a bare counts[activity.kind] = counts[activity.kind] + 1, with no branch that starts an unseen kind at 0. Predict what countByKind(activities) returns before running. The first time a kind is seen, counts[kind] is undefined, and undefined + 1 is NaN — so every kind comes out NaN, showing why the count-up object needs an initial value.

Arrange the code

These four lines hold a list of durations, total them, gather the total together with a count into one summary object, and report it. The pieces are shuffled. Put them back in an order that runs — then answer what the puzzle is really testing: which line could not have moved, and what does the runner do if it does?

  1. const total = minutes.reduce((sum, m) => sum + m, 0);
  2. const minutes: number[] = [30, 20, 50];
  3. console.log(`${summary.count} activities, ${summary.total} minutes`);
  4. const summary = { count: minutes.length, total };
Continue learning

Transfer

An array in JavaScript is one thing: an ordered, growable list, and any element can hold anything. TypeScript describes that same runtime object in several different ways depending on what you intend - number[], the tuple [string, number], and a Record<string, number> keyed by name. All three compile to ordinary JavaScript objects and arrays. Which statement explains what choosing between them actually buys you?

Continue learning

Capstone milestone

Milestone — the data behind the domain model. The capstone tracks learning activities, and the very first layer is the shape of the thing it tracks: a typed collection of activity records, each with a title, a kind, and its own fields. This lesson's arrays-of-typed-objects are that exact data shape in miniature. Confirm you can model and read it.

Hint: You are not building the full domain model yet — the capstone sharpens the kind field into a discriminated union of lesson, quiz, and practice activities (you'll meet unions and narrowing in later lessons). Here you confirm the typed-collection foundation it rests on: an interface plus an array of records you can read and aggregate.

  • Defined an interface describing the fields of one record
  • Built an array of objects that all satisfy that interface
  • Read a field off a record with dot notation
  • Aggregated across the array (a count, a total, or a projection) with a loop
Continue learning

Key Takeaways

  • Arrays store ordered collections of the same type
  • Objects store key-value pairs for complex data
  • Use type aliases and interfaces to define object shapes
  • Tuples have fixed length and specific types for each position
  • Enums define named constants
  • Maps store key-value pairs with any key type
  • Sets store unique values only
  • Array methods like map, filter, and reduce are powerful tools

Pro Tip: Choose the right data structure for your needs. Arrays for ordered lists, objects for structured data, Maps for flexible key-value storage, and Sets for unique collections!

Next Steps

You've been using inline type annotations and basic interfaces to describe objects. Next, you'll dive deep into interfaces and type aliases — learning when to use each, how to extend them, and how intersection types let you compose complex shapes from simple ones.

Ready to continue? Head to Interfaces and Types!

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.