TypeDrop

2026-03-12 Challenge

2026-03-12 Easy

Typed Expense Report Summariser

You're building the finance module for a small business app. Raw expense entries arrive as unknown JSON from a mobile upload, and you must validate them, categorise them, and produce a per-category summary — all with zero `any` and fully typed results.

Goals

  • Define a generic discriminated-union `Result<T, E>` type and use it as the return type of `validateExpense`.
  • Implement `isExpenseCategory` as a proper TypeScript type guard that narrows `unknown` to `ExpenseCategory`.
  • Write `validateExpense` so it collects ALL field-level `ValidationError`s in a single pass before returning `ok: false`.
  • Wire everything together in `processRawExpenses`, separating valid expenses from invalid ones and building a sorted `ExpenseReport`.
challenge.ts

// Core domain types
type ExpenseCategory = "travel" | "meals" | "software" | "hardware" | "other";

interface Expense {
  id: string;
  description: string;
  amountCents: number;   // integer > 0
  category: ExpenseCategory;
  date: string;          // "YYYY-MM-DD"
  submittedBy: string;
}

// Result discriminated union — your job to define it!
type Result<T, E> = /* TODO */;

// Type guard — must narrow to ExpenseCategory
function isExpenseCategory(value: unknown): value is ExpenseCategory { /* TODO */ }

// Validates raw unknown → typed Expense, collecting ALL field errors
function validateExpense(raw: unknown): Result<Expense, ValidationError[]> { /* TODO */ }

// Groups a validated list into a Map keyed by category
function groupByCategory(expenses: Expense[]): Map<ExpenseCategory, Expense[]> { /* TODO */ }

// Orchestrates validation + grouping → final typed report
function processRawExpenses(rawEntries: unknown[]): {
  report: ExpenseReport;
  invalid: Array<{ raw: unknown; errors: ValidationError[] }>;
} { /* TODO */ }
Hints (click to reveal)

Hints

  • A discriminated union on a boolean literal (`ok: true` vs `ok: false`) is the cleanest way to model `Result<T, E>` — TypeScript will narrow the type automatically in `if (result.ok)` branches.
  • In `validateExpense`, check `typeof raw === 'object' && raw !== null` first, then use `'id' in raw` + `typeof (raw as Record<string, unknown>).id` to access each field safely without `any`.
  • `Array.prototype.reduce` over `Map.entries()` is a clean single-pass way to compute `totalCents` and `count` for each `ExpenseSummary` inside `buildReport`.

Or clone locally

git clone -b challenge/2026-03-12 https://github.com/niltonheck/typedrop.git