Skip to main content
Version: v1.4

✏️ 3.2.4.1 Book Tracker: Part 4

Let's finish up our book tracker by adding some search and sort functionality. We'll also review our code and write a README to document the project.

Warm Up

  • How does Array.prototype.sort work? What does the comparison function return, and what do the different return values mean?
  • What's the difference between ?? and ||? When does the difference matter?

Searching by Title

searchByTitle does a case-insensitive search and returns all books whose title contains the query string:

src/library.ts
export function searchByTitle(books: Book[], query: string): Book[] {
const lowerQuery = query.toLowerCase();
return books.filter(book => book.title.toLowerCase().includes(lowerQuery));
}

Converting both the query and the title to lowercase before comparing means "uprooted", "Uprooted", and "UPROOTED" all find the same book. The function signature is straightforward - query is just a string, and TypeScript doesn't need anything more specific.

Test searchByTitle in index.ts:

src/index.ts
import { addBook, filterByStatus, filterByGenre, removeBook, updateStatus, averageRating, findById, updateRating, getStatusCounts, searchByTitle } from "./library";

// ... existing code ...

const results = searchByTitle(books, "the");
console.log('searchByTitle', results.map(b => b.title)); // all titles containing "the"

Sorting by Year

sortByYear returns a new array of books sorted by publication year. The direction parameter defaults to "asc" if not provided:

src/library.ts
export function sortByYear(books: Book[], direction: "asc" | "desc" = "asc"): Book[] {
return [...books].sort((a, b) =>
direction === "asc" ? a.year - b.year : b.year - a.year
);
}

The spread operator [...books] creates a copy of the array before sorting. This is important because Array.prototype.sort sorts in place - it modifies the original array. Since we're following the immutability pattern throughout this project, we copy first and sort the copy.

The comparison function returns a negative number if a should come before b, a positive number if b should come before a, and zero if their order doesn't matter. Subtracting years gives exactly this behavior.

Test sortByYear:

src/index.ts
import { addBook, filterByStatus, filterByGenre, removeBook, updateStatus, averageRating, findById, updateRating, getStatusCounts, searchByTitle, sortByYear } from "./library";

// ... existing code ...

const oldestFirst = sortByYear(books, "asc");
const newestFirst = sortByYear(books, "desc");
console.log('sortByYear asc', oldestFirst.map(b => b.title)); // titles sorted from oldest to newest
console.log('sortByYear desc', newestFirst.map(b => b.title)); // titles sorted from newest to oldest

Sorting by Rating

Sorting by rating is more complex because rating is optional. For the purpose of this exercise, books with no rating should always appear at the end regardless of sort direction:

src/library.ts
export function sortByRating(books: Book[], direction: "asc" | "desc" = "desc"): Book[] {
return [...books].sort((a, b) => {
if (a.rating === undefined && b.rating === undefined) return 0;
if (a.rating === undefined) return 1;
if (b.rating === undefined) return -1;
return direction === "asc" ? a.rating - b.rating : b.rating - a.rating;
});
}

We handle the undefined cases before doing any arithmetic. Returning 1 when a.rating is missing pushes a toward the end; returning -1 when b.rating is missing pushes b toward the end. Once we've ruled out undefined, TypeScript knows both ratings are numbers and the arithmetic is safe.

Test sortByRating with a mix of rated and unrated books to make sure unrated books always appear last.

src/index.ts
import { addBook, filterByStatus, filterByGenre, removeBook, updateStatus, averageRating, findById, updateRating, getStatusCounts, searchByTitle, sortByYear, sortByRating } from "./library";

// ... existing code ...

const byRatingDesc = sortByRating(books, "desc");
const byRatingAsc = sortByRating(books, "asc");
console.log('sortByRating desc', byRatingDesc.map(b => `${b.title} (${b.rating ?? "no rating"})`)); // titles sorted from highest to lowest rating, unrated last
console.log('sortByRating asc', byRatingAsc.map(b => `${b.title} (${b.rating ?? "no rating"})`)); // titles sorted from lowest to highest rating, unrated last

Refactoring Check

Before writing your README, look back over library.ts with fresh eyes:

  • Are there any places where you used any? Replace it with a proper type.
  • Are your function signatures as specific as they can be? Check that parameter types are as narrow as useful.
  • Are there any repeated patterns that a utility type could simplify?
  • Does every function compile cleanly with npx tsc --noEmit?

README

Create a README.md at the root of your project. Include:

  • What the project does
  • How to run it (npx ts-node src/index.ts)
  • A list of the functions available and what each one does

Complete library.ts

Your finished library.ts should look like this:

src/library.ts
import { Book, Genre, NewBook, ReadStatus } from "./types";

export function addBook(books: Book[], newBook: NewBook): Book[] {
const nextId = books.length === 0 ? 1 : Math.max(...books.map(b => b.id)) + 1;
const book: Book = { ...newBook, id: nextId };
return [...books, book];
}

export function filterByStatus(books: Book[], status: ReadStatus): Book[] {
return books.filter(book => book.status === status);
}

export function removeBook(books: Book[], id: number): Book[] {
return books.filter(book => book.id !== id);
}

export function updateStatus(books: Book[], id: number, status: ReadStatus): Book[] {
return books.map(book => {
if (book.id === id) {
return { ...book, status };
}
return book;
});
}

export function filterByGenre(books: Book[], genre: Genre): Book[] {
return books.filter(book => book.genre === genre);
}

export function averageRating(books: Book[]): number | null {
let total = 0;
let count = 0;

for (const book of books) {
if (book.rating !== undefined) {
total += book.rating;
count++;
}
}

return count === 0 ? null : total / count;
}

export function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}

export function updateRating(books: Book[], id: number, rating: number): Book[] {
if (rating < 1 || rating > 5) {
return books;
}

return books.map(book => {
if (book.id === id) {
return { ...book, rating };
}
return book;
});
}

export function getStatusCounts(books: Book[]): Record<ReadStatus, number> {
const counts: Record<ReadStatus, number> = {
"want-to-read": 0,
"currently-reading": 0,
"finished": 0,
};

for (const book of books) {
counts[book.status]++;
}

return counts;
}

export function searchByTitle(books: Book[], query: string): Book[] {
const lowerQuery = query.toLowerCase();
return books.filter(book => book.title.toLowerCase().includes(lowerQuery));
}

export function sortByYear(books: Book[], direction: "asc" | "desc" = "asc"): Book[] {
return [...books].sort((a, b) =>
direction === "asc" ? a.year - b.year : b.year - a.year
);
}

export function sortByRating(books: Book[], direction: "asc" | "desc" = "desc"): Book[] {
return [...books].sort((a, b) => {
if (a.rating === undefined && b.rating === undefined) return 0;
if (a.rating === undefined) return 1;
if (b.rating === undefined) return -1;
return direction === "asc" ? a.rating - b.rating : b.rating - a.rating;
});
}

Congratulations on finishing the book tracker! This was a big project with a lot of different pieces, so it's a great accomplishment to have it all working together. Take some time to review your code and make sure you're happy with how everything turned out.