Tier 1 — Beginner

Contact Book

Store, search, and manage contacts — Map-based CRUD, JSON persistence, phone/email validation.

🐍 Python 🟨 JavaScript

1. The Problem

Build a contact book CLI: add contacts with name, phone, and email. List all contacts. Search by name. Delete by name. Persist to a JSON file between sessions.

Why this project? Full CRUD on a Map store, regex-based validation, JSON persistence, and case-insensitive search — the same patterns behind every address book, CRM, and user database.

2. How to Think About This

Store contacts in a Map keyed by lowercase name for O(1) lookup and case-insensitive access. Validate phone (digits only, 7–15 chars) and email (contains @ and .) before storing. Persist by serialising the Map to a JSON array on every mutation.

3. The Build

contacts.mjs
import { readFileSync, writeFileSync, existsSync } from "fs";

const DATA = "contacts.json";

export function validatePhone(phone) {
  return /^\+?[\d\s\-().]{7,15}$/.test(phone);
}
export function validateEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export class ContactBook {
  #contacts;
  constructor(contacts = []) {
    this.#contacts = new Map(contacts.map(c => [c.name.toLowerCase(), c]));
  }
  add({ name, phone, email }) {
    if (!name?.trim())             throw new Error("Name is required");
    if (phone && !validatePhone(phone))  throw new Error("Invalid phone number");
    if (email && !validateEmail(email))  throw new Error("Invalid email address");
    const key = name.trim().toLowerCase();
    if (this.#contacts.has(key))   throw new Error(`Contact "${name}" already exists`);
    const contact = { name: name.trim(), phone: phone?.trim() || "", email: email?.trim() || "" };
    this.#contacts.set(key, contact);
    return contact;
  }
  get(name)    { return this.#contacts.get(name.toLowerCase()) ?? null; }
  delete(name) { return this.#contacts.delete(name.toLowerCase()); }
  search(q) {
    const lq = q.toLowerCase();
    return [...this.#contacts.values()].filter(c =>
      c.name.toLowerCase().includes(lq) ||
      c.phone.includes(lq) ||
      c.email.toLowerCase().includes(lq)
    );
  }
  all()    { return [...this.#contacts.values()].sort((a,b) => a.name.localeCompare(b.name)); }
  toJSON() { return JSON.stringify([...this.#contacts.values()], null, 2); }
}

function load() {
  if (!existsSync(DATA)) return new ContactBook();
  try { return new ContactBook(JSON.parse(readFileSync(DATA, "utf-8"))); } catch { return new ContactBook(); }
}
function save(book) { writeFileSync(DATA, book.toJSON()); }

const [,, cmd, name, ...rest] = process.argv;
const book = load();

switch (cmd) {
  case "add": {
    if (!name) { console.error("Usage: node contacts.mjs add <name> [phone] [email]"); break; }
    try {
      const c = book.add({ name, phone: rest[0], email: rest[1] });
      save(book);
      console.log(`Added: ${c.name}${c.phone ? "  " + c.phone : ""}${c.email ? "  " + c.email : ""}`);
    } catch(e) { console.error(e.message); }
    break;
  }
  case "list":
    if (!book.all().length) { console.log("No contacts yet."); break; }
    book.all().forEach(c => console.log(`  ${c.name.padEnd(20)} ${c.phone.padEnd(15)} ${c.email}`));
    console.log(`
  ${book.all().length} contact(s)`);
    break;
  case "search":
    if (!name) { console.error("Usage: node contacts.mjs search <query>"); break; }
    const results = book.search(name);
    results.length ? results.forEach(c => console.log(`  ${c.name}  ${c.phone}  ${c.email}`))
                   : console.log("No matches.");
    break;
  case "delete":
    if (!name) { console.error("Usage: node contacts.mjs delete <name>"); break; }
    book.delete(name) ? (save(book), console.log(`Deleted: ${name}`)) : console.error(`Not found: ${name}`);
    break;
  default:
    console.log("Commands: add <name> [phone] [email] | list | search <query> | delete <name>");
}

4. Test & Prove

import { ContactBook, validatePhone, validateEmail } from "./contacts.mjs";
let p=0,f=0;
function test(d,fn){try{fn();console.log("  ✓",d);p++;}catch(e){console.log("  ✗",d,"—",e.message);f++;}}
const book = new ContactBook();
book.add({ name: "Alice", phone: "+91 9876543210", email: "alice@example.com" });
book.add({ name: "Bob",   phone: "022-1234567" });
test("all() returns sorted", () => { if(book.all()[0].name!=="Alice") throw new Error(); });
test("get case-insensitive", () => { if(!book.get("alice")) throw new Error(); });
test("search by name",       () => { if(book.search("ali").length!==1) throw new Error(); });
test("duplicate throws",     () => { try{book.add({name:"Alice"});throw new Error("no");}catch(e){if(e.message==="no")throw e;} });
test("delete works",         () => { book.delete("Bob"); if(book.all().length!==1) throw new Error(); });
test("valid phone",          () => { if(!validatePhone("+91 9876543210")) throw new Error(); });
test("invalid phone",        () => { if(validatePhone("abc")) throw new Error(); });
test("valid email",          () => { if(!validateEmail("a@b.com")) throw new Error(); });
test("invalid email",        () => { if(validateEmail("notanemail")) throw new Error(); });
console.log(`
${p} passed, ${f} failed`);

5. The Interface

node contacts.mjs add "Priya Sharma" "+91 9876543210" "priya@example.com"
node contacts.mjs add "Rahul"
node contacts.mjs list
node contacts.mjs search priya
node contacts.mjs delete Rahul

6. Run It

node contacts.mjs add "Alice" "+91 9876543210" "alice@example.com"
node contacts.mjs list
node contacts.test.mjs

🎯 Try this next

  1. Add an edit <name> command using readline prompts
  2. Add groups/tags: --tag work and filter by tag on list
  3. Export to CSV: node contacts.mjs export > contacts.csv
  4. Port to a browser address book with search-as-you-type filtering

What you practised

Map keyed by lowercase name for case-insensitive O(1) lookup, regex validation for phone and email, CRUD operations, JSON persistence, and localeCompare for alphabetical sorting.

Reference: Set & Map · Regular Expressions · OOP & Classes

Try It Yourself