TypeScript · Projects

Practical Projects

You have learned the pieces. These projects make them stick. Each one focuses on a few TypeScript skills — start with the first and work down.

Advanced 6 min Lesson 29 of 29Examples target TypeScript 5.x with "strict": true.
0/29 in TypeScript
On this page · 6 min · Advanced

Learning objectives#

  • Model a domain with interfaces, unions and literal types
  • Write reusable generic code
  • Handle external data safely
  • Migrate JavaScript to TypeScript
  • Build portfolio pieces that show TypeScript skills

Explanation#

Project 1 — Expense tracker (beginner): Expense interface, Category literal union, functions to add, filter and total expenses, Record<Category, number> for summaries.

Project 2 — Generic data table (intermediate): a function or React component that sorts and filters any T[] with columns typed as { key: keyof T; label: string }[].

Project 3 — Typed API client (intermediate): api<T>() helper, ApiError class, request/response DTOs derived with Omit and Partial, type guards for responses.

Project 4 — Form validator (intermediate): Validator types, a schema typed as Record<keyof FormValues, Validator[]>, results typed as Partial<Record<keyof FormValues, string>>.

Project 5 — Migration (advanced): take an old JavaScript project, add tsconfig with allowJs, rename files one at a time, then enable strict and fix the errors.

Code example: Project 1 — Typed expense tracker (solution)#

TypeScriptProject 1 — Typed expense tracker (solution) Illustrative
const CATEGORIES = ['food', 'travel', 'bills', 'shopping'] as const;
type Category = (typeof CATEGORIES)[number];


interface Expense {
  readonly id: number;
  title: string;
  amount: number;
  category: Category;
  date: Date;
}


type NewExpense = Omit<Expense, 'id'>;


class ExpenseTracker {
  #expenses: Expense[] = [];
  #nextId = 1;


  add(data: NewExpense): Expense {
    if (data.amount <= 0) throw new Error('Amount must be positive');
    const expense = { id: this.#nextId++, ...data };
    this.#expenses.push(expense);
    return expense;
  }


  byCategory(category: Category): Expense[] {
    return this.#expenses.filter(e => e.category === category);
  }


  summary(): Record<Category, number> {
    const totals = Object.fromEntries(CATEGORIES.map(c => [c, 0])) as Record<Category, number>;
    for (const e of this.#expenses) totals[e.category] += e.amount;
    return totals;
  }
}


const tracker = new ExpenseTracker();
tracker.add({ title: 'Lunch', amount: 250, category: 'food', date: new Date() });
tracker.add({ title: 'Metro card', amount: 500, category: 'travel', date: new Date() });
tracker.add({ title: 'Dinner', amount: 400, category: 'food', date: new Date() });
console.log(tracker.summary());
tracker.add({ title: 'Movie', amount: 300, category: 'fun', date: new Date() });
// ✗ Type '"fun"' is not assignable to type 'Category'
Expected output
{ food: 650, travel: 500, bills: 0, shopping: 0 } — and a compile error for the unknown "fun" category.

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. CATEGORIES as const is the single source for both the runtime list and the Category type.
  2. Expense uses readonly id and the Category union.
  3. NewExpense is derived with Omit, so callers never invent ids.
  4. Private #fields keep the data safe inside the class.
  5. summary() returns a Record that is guaranteed to contain every category.
  6. Invalid categories are rejected at compile time.

Real-world use case#

These projects mirror real tasks: modelling domain data, building reusable UI pieces, talking to APIs and modernising old code — exactly what TypeScript jobs involve.

Common mistakes#

Reaching for any when stuck

Use unknown and narrow it, or ask “what shape is this really?” and write the type. Each any you avoid is a skill learned.

Over-engineering types

Clever conditional types are fun but hard to read. Prefer simple interfaces and unions until you need more.

Best practices#

  • Start each project by writing the types first
  • Keep strict mode on
  • Derive types instead of duplicating them
  • Put projects on GitHub with a README explaining the type design

Remember this#

Types first, code second — the types are your design.

One source of truth (as const, interfaces) → derive the rest.

Stuck? unknown + narrowing, not any.

Practice exercise#

Your task: Project 2 starter: write sortBy<T>(items: T[], key: keyof T, dir: "asc" | "desc" = "asc"): T[] that returns a sorted copy.

Need a hint?
Use toSorted (or [...items].sort) and compare a[key] with b[key].
Show solution
TypeScript Illustrative
function sortBy<T>(items: T[], key: keyof T, dir: 'asc' | 'desc' = 'asc'): T[] {
  const factor = dir === 'asc' ? 1 : -1;
  return [...items].sort((a, b) => (a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0) * factor);
}


const people = [{ name: 'Ravi', age: 30 }, { name: 'Asha', age: 24 }];
sortBy(people, 'age');        // Asha, Ravi
sortBy(people, 'email');      // ✗ not a key of the items

Mini quiz#

Practical Projects quizMultiple choice Question 1 of 3

Why define CATEGORIES as const and derive Category from it?

Score so far: 0/0

Want more? Practise all TypeScript MCQs · TypeScript interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.