// Beginner project · about 3 hours of work
Typed expense splitter
You build a small strict TypeScript program for a group trip. The entries are a typed array literal in trip.ts: someone paid for dinner that three people share, someone owes someone for a ticket, someone paid a debt back. A discriminated union models the three kinds, paid, owed and settled, so every switch over an entry must handle all of them: add a fourth kind without handling it and tsc stops you, which checks.ts proves with @ts-expect-error. The program adds everything up into a balance per person and prints who pays whom to settle up. Money is kept as whole cents in a number that is always an integer, because 0.1 + 0.2 is not exactly 0.3 in floating point, while 10 + 20 cents is exactly 30; an amount that does not split evenly gives its extra cents to the first people in the list. It uses the Beginner level: annotations, object types and aliases, literal types, unions and narrowing, discriminated unions, never, readonly, index signatures and arrays.
What the finished program does
- npx tsc passes with the course tsconfig (strict on, noEmit), with no any and no type assertions, and node main.ts prints the report for the trip in trip.ts.
- Entry is a discriminated union of three object types with readonly properties and a kind property: paid (payer, cents, sharedBy, note), owed (from owes to: from, to, cents, note) and settled (from paid to back: from, to, cents).
- Every amount is whole cents (an integer number). splitCents(cents, people) splits an amount equally; when it does not divide evenly, the first people in the list pay one cent more each, so the shares always add up to the amount. It throws a RangeError for an empty list and for cents that are not a whole number above 0 (Number.isInteger).
- describeEntry(entry) returns one line per kind, switching on entry.kind with return assertNever(entry) in the default branch: Ana paid 90.00 for dinner, shared by Ana, Ben, Cleo / Cleo owes Ana 12.00 for the train / Ben paid Ana back 10.00.
- balances(entries) returns an object with an index signature, {[person: string]: number}: a paid entry adds the amount to the payer and takes each share from the people who share it; owed takes the amount from from and gives it to to; settled does the opposite. The balances of any list of entries add up to 0.
- balanceLines(totals) returns one line per person in alphabetical order: Ana gets back 58.66, Ben owes 0.83, or Dev is even.
- settleUp(totals) returns the transfers that make everyone even: the largest debtor pays the largest creditor as much as both allow, and so on (ties by name). A group that is already even gets no transfers.
- checks.ts, which tsc checks but nothing runs, holds two @ts-expect-error lines: an Entry with an unknown kind, and a switch over Entry plus a new Refunded type that does not handle refunded. If either line stopped being an error, tsc would report error TS2578: Unused '@ts-expect-error' directive.
Starter layout
- main.ts
- Your work: the Entry union, formatCents, splitCents, describeEntry, balances, balanceLines and settleUp. Paid, Balances, Transfer, assertNever and report are given; the last line prints the report for the trip.
- trip.ts
- The fixture: the trip's five entries as a typed array literal, readonly Entry[]. Given; it imports Entry with import type.
- checks.ts
- The compile-time proof with two @ts-expect-error lines. Given; tsc checks it, nothing runs it. Read it, and do not change it.
- main.test.ts
- The acceptance tests (node:test). Not type-checked (the tsconfig excludes *.test.ts). In the starter download.
- tsconfig.json
- The course tsconfig: strict, noEmit, erasableSyntaxOnly, verbatimModuleSyntax, allowImportingTsExtensions. In the starter download.
- package.json
- {"type": "module"}, so Node treats the .ts files as ES modules. In the starter download.
- learnrun.js
- The course helper with runMain(), which the last test uses to run main.ts. In the starter download.
Milestones
Milestone 1
Money as whole cents
Write formatCents with Math.floor(cents / 100) and String(cents % 100).padStart(2, '0'). Write splitCents: check the input first and throw a RangeError, then give everyone Math.floor(cents / people.length) and one more cent to the first cents % people.length people. Integer arithmetic only: Math.floor keeps every share a whole number of cents.
Checks that pass once this milestone is done:
- formatCents writes whole cents as euros and cents
- splitCents shares an amount exactly, the first people taking the extra cents
- splitCents refuses no people and amounts that are not whole cents above 0
Milestone 2
The discriminated union
Write Owed and Settled next to Paid, each with its own literal kind, and make Entry = Paid | Owed | Settled. trip.ts now type-checks. Write describeEntry as a switch on entry.kind; in each case tsc has narrowed entry, so entry.payer is only allowed under paid. Finish with default: return assertNever(entry). Now checks.ts type-checks too, and npx tsc prints nothing.
Checks that pass once this milestone is done:
- describeEntry writes one line for each kind of entry
Milestone 3
Balances per person
Write balances with const result: Balances = {} and a small add(person, cents) helper that starts a missing person at 0 with ??. Switch on entry.kind again, with assertNever in default; a paid entry uses splitCents, so the shares match the amount to the cent.
Checks that pass once this milestone is done:
- balances adds up paid, owed and settled entries
- The balances of the trip add up to zero
Milestone 4
Who owes whom
Write balanceLines with Object.keys(totals).sort(). Write settleUp: a list of debtors (balance below 0, amount turned positive) and one of creditors, both sorted largest first and by name on a tie. Walk both lists with two indexes and push {from, to, cents} with the smaller of the two amounts. Then run npx tsc and node main.ts.
Checks that pass once this milestone is done:
- balanceLines says who gets money back, who owes and who is even
- settleUp pays the largest debt to the largest creditor first
- The program prints the entries, balances and transfers of the trip
Build it in the browser
Edit main.ts below; the other starter files stay as they are. “Check my code” runs the type check and every acceptance test, so expect failures until the last milestone.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.
Build it on your computer
Make a folder with these starter files and TypeScript 7.0, then work through the milestones. Run the acceptance tests at any point with:
Download the starter as one .zip (starter files, main.test.ts, package.json, tsconfig.json and learnrun.js)npx tsc
node --testnpx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.
Download learnrun.jsmain.ts
// Typed expense splitter: who owes whom after a shared trip.
// Money is kept in whole cents (integers), so every sum is exact.
import {trip} from './trip.ts';
/** Someone paid an expense that the people in sharedBy split equally. */
export type Paid = {
readonly kind: 'paid';
readonly payer: string;
readonly cents: number;
readonly sharedBy: readonly string[];
readonly note: string;
};
// Milestone 2: write Owed (from owes to: from, to, cents, note) and
// Settled (from paid back to: from, to, cents), each with its own kind,
// and make Entry the union of all three.
export type Entry = Paid;
/** Each person's balance in cents: above 0 gets money back, below 0 owes. */
export type Balances = {[person: string]: number};
/** One payment that settles a debt. */
export type Transfer = {readonly from: string; readonly to: string; readonly cents: number};
export function assertNever(value: never): never {
throw new Error(`Unexpected entry: ${JSON.stringify(value)}`);
}
/** 1250 is "12.50", 5 is "0.05". */
export function formatCents(cents: number): string {
// Milestone 1: Math.floor(cents / 100), then cents % 100 with padStart.
return String(cents);
}
/** Split cents equally between people; the first people in the list take the extra cents. */
export function splitCents(cents: number, people: readonly string[]): number[] {
// Milestone 1: throw a RangeError for no people, or cents that are not a whole number above 0.
return [];
}
/** One line for one entry. */
export function describeEntry(entry: Entry): string {
// Milestone 2: switch on entry.kind, one case per kind, and return assertNever(entry) in default.
return '';
}
/** Add up every entry into one balance per person. */
export function balances(entries: readonly Entry[]): Balances {
// Milestone 3: start from {} and add or subtract each entry's cents.
return {};
}
/** One line per person, in alphabetical order. */
export function balanceLines(totals: Balances): string[] {
// Milestone 4: "Ana gets back 58.66", "Ben owes 0.83" or "Dev is even".
return [];
}
/** The largest debt is paid to the largest creditor first, until everyone is even. */
export function settleUp(totals: Balances): Transfer[] {
// Milestone 4: two lists, debtors and creditors, each sorted largest first.
return [];
}
/** Everything the program prints for a list of entries. */
export function report(entries: readonly Entry[]): string[] {
const totals = balances(entries);
return [
'Entries:',
...entries.map((entry) => ` ${describeEntry(entry)}`),
'Balances:',
...balanceLines(totals).map((line) => ` ${line}`),
'To settle up:',
...settleUp(totals).map((t) => ` ${t.from} pays ${t.to} ${formatCents(t.cents)}`)
];
}
for (const line of report(trip)) console.log(line);
trip.ts
// The fixture: a weekend trip of three friends, in whole cents.
import type {Entry} from './main.ts';
export const trip: readonly Entry[] = [
{kind: 'paid', payer: 'Ana', cents: 9000, sharedBy: ['Ana', 'Ben', 'Cleo'], note: 'dinner'},
{kind: 'paid', payer: 'Ben', cents: 4500, sharedBy: ['Ben', 'Cleo'], note: 'museum tickets'},
{kind: 'paid', payer: 'Cleo', cents: 1000, sharedBy: ['Ana', 'Ben', 'Cleo'], note: 'coffee'},
{kind: 'owed', from: 'Cleo', to: 'Ana', cents: 1200, note: 'the train'},
{kind: 'settled', from: 'Ben', to: 'Ana', cents: 1000}
];
checks.ts
// Compile-time checks: tsc type-checks this file, nothing runs it.
import type {Entry} from './main.ts';
import {assertNever, describeEntry} from './main.ts';
// 1. A kind that is not part of Entry is rejected.
// @ts-expect-error: 'refunded' is not a kind of Entry
const unknownKind: Entry = {kind: 'refunded', from: 'Ana', to: 'Ben', cents: 500};
// 2. A new variant that a switch does not handle fails to compile.
type Refunded = {readonly kind: 'refunded'; readonly from: string; readonly to: string; readonly cents: number};
export function describeWithRefunds(entry: Entry | Refunded): string {
switch (entry.kind) {
case 'paid':
case 'owed':
case 'settled':
return describeEntry(entry);
default:
// @ts-expect-error: 'refunded' is not handled, so entry is a Refunded here, not never
return assertNever(entry);
}
}
export {unknownKind};
package.json
{
"type": "module"
}
tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"lib": [
"esnext",
"dom"
],
"types": [],
"strict": true,
"noEmit": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true
},
"include": [
"**/*.ts"
],
"exclude": [
"**/*.test.ts"
]
}
Acceptance tests
The project is done when the type check and every check in main.test.ts pass. Read them before you start: they are the spec, written as code.
main.test.ts
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';
import {balanceLines, balances, describeEntry, formatCents, settleUp, splitCents} from './main.ts';
import type {Entry} from './main.ts';
import {trip} from './trip.ts';
const lunch: Entry[] = [
{kind: 'paid', payer: 'Ana', cents: 3000, sharedBy: ['Ana', 'Ben'], note: 'lunch'},
{kind: 'owed', from: 'Ana', to: 'Dev', cents: 400, note: 'a coffee'},
{kind: 'settled', from: 'Ben', to: 'Ana', cents: 500}
];
test('formatCents writes whole cents as euros and cents', () => {
assert.equal(formatCents(1250), '12.50', 'formatCents(1250)');
assert.equal(formatCents(5), '0.05', 'formatCents(5)');
assert.equal(formatCents(80000), '800.00', 'formatCents(80000)');
});
test('splitCents shares an amount exactly, the first people taking the extra cents', () => {
assert.deepEqual(splitCents(1000, ['Ana', 'Ben', 'Cleo']), [334, 333, 333], 'splitCents(1000, three people)');
assert.deepEqual(splitCents(4500, ['Ben', 'Cleo']), [2250, 2250], 'splitCents(4500, two people)');
assert.deepEqual(splitCents(2, ['Ana', 'Ben', 'Cleo']), [1, 1, 0], 'splitCents(2, three people)');
});
test('splitCents refuses no people and amounts that are not whole cents above 0', () => {
assert.throws(() => splitCents(1000, []), RangeError, 'splitCents(1000, []) should throw a RangeError');
assert.throws(() => splitCents(0, ['Ana']), RangeError, 'splitCents(0, ...) should throw a RangeError');
assert.throws(() => splitCents(12.5, ['Ana']), RangeError, 'splitCents(12.5, ...) should throw a RangeError');
});
test('describeEntry writes one line for each kind of entry', () => {
assert.equal(describeEntry(lunch[0]), 'Ana paid 30.00 for lunch, shared by Ana, Ben', 'the paid entry');
assert.equal(describeEntry(lunch[1]), 'Ana owes Dev 4.00 for a coffee', 'the owed entry');
assert.equal(describeEntry(lunch[2]), 'Ben paid Ana back 5.00', 'the settled entry');
});
test('balances adds up paid, owed and settled entries', () => {
assert.deepEqual(balances(lunch), {Ana: 600, Ben: -1000, Dev: 400}, 'balances of the lunch entries');
assert.deepEqual(balances([]), {}, 'balances of no entries');
});
test('The balances of the trip add up to zero', () => {
const totals = balances(trip);
assert.deepEqual(totals, {Ana: 5866, Ben: -83, Cleo: -5783}, 'balances of the trip');
const sum = Object.values(totals).reduce((a, b) => a + b, 0);
assert.equal(sum, 0, 'the balances of every group must add up to 0 cents');
});
test('balanceLines says who gets money back, who owes and who is even', () => {
assert.deepEqual(
balanceLines({Cleo: -5783, Ana: 5866, Dev: 0, Ben: -83}),
['Ana gets back 58.66', 'Ben owes 0.83', 'Cleo owes 57.83', 'Dev is even'],
'one line per person, in alphabetical order'
);
});
test('settleUp pays the largest debt to the largest creditor first', () => {
assert.deepEqual(
settleUp({Ana: 5866, Ben: -83, Cleo: -5783}),
[
{from: 'Cleo', to: 'Ana', cents: 5783},
{from: 'Ben', to: 'Ana', cents: 83}
],
'the transfers for the trip'
);
assert.deepEqual(
settleUp({Ana: -700, Ben: 300, Cleo: 400}),
[
{from: 'Ana', to: 'Cleo', cents: 400},
{from: 'Ana', to: 'Ben', cents: 300}
],
'one debtor and two creditors'
);
assert.deepEqual(settleUp({Ana: 0, Ben: 0}), [], 'a group that is already even needs no transfers');
});
test('The program prints the entries, balances and transfers of the trip', async () => {
const printed = (await runMain()).trimEnd().split('\n');
const expected = [
'Entries:',
' Ana paid 90.00 for dinner, shared by Ana, Ben, Cleo',
' Ben paid 45.00 for museum tickets, shared by Ben, Cleo',
' Cleo paid 10.00 for coffee, shared by Ana, Ben, Cleo',
' Cleo owes Ana 12.00 for the train',
' Ben paid Ana back 10.00',
'Balances:',
' Ana gets back 58.66',
' Ben owes 0.83',
' Cleo owes 57.83',
'To settle up:',
' Cleo pays Ana 57.83',
' Ben pays Ana 0.83'
];
assert.deepEqual(printed, expected, `the program printed:\n${printed.join('\n')}`);
});
Run the finished program
npx tsc
node main.tsReference solution
Try the milestones first. This solution passes every acceptance test and the type checker.
main.ts
// Typed expense splitter: who owes whom after a shared trip.
// Money is kept in whole cents (integers), so every sum is exact.
import {trip} from './trip.ts';
/** Someone paid an expense that the people in sharedBy split equally. */
export type Paid = {
readonly kind: 'paid';
readonly payer: string;
readonly cents: number;
readonly sharedBy: readonly string[];
readonly note: string;
};
/** from owes to an amount directly, for example for a ticket bought for them. */
export type Owed = {
readonly kind: 'owed';
readonly from: string;
readonly to: string;
readonly cents: number;
readonly note: string;
};
/** from paid back to some of what they owed. */
export type Settled = {
readonly kind: 'settled';
readonly from: string;
readonly to: string;
readonly cents: number;
};
export type Entry = Paid | Owed | Settled;
/** Each person's balance in cents: above 0 gets money back, below 0 owes. */
export type Balances = {[person: string]: number};
/** One payment that settles a debt. */
export type Transfer = {readonly from: string; readonly to: string; readonly cents: number};
export function assertNever(value: never): never {
throw new Error(`Unexpected entry: ${JSON.stringify(value)}`);
}
/** 1250 is "12.50", 5 is "0.05". */
export function formatCents(cents: number): string {
return `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, '0')}`;
}
/** Split cents equally between people; the first people in the list take the extra cents. */
export function splitCents(cents: number, people: readonly string[]): number[] {
if (people.length === 0) throw new RangeError('an expense must be shared by at least one person');
if (!Number.isInteger(cents) || cents <= 0) throw new RangeError(`cents must be a whole number above 0, got ${cents}`);
const share = Math.floor(cents / people.length);
const extra = cents % people.length;
return people.map((_, i) => (i < extra ? share + 1 : share));
}
/** One line for one entry. */
export function describeEntry(entry: Entry): string {
switch (entry.kind) {
case 'paid':
return `${entry.payer} paid ${formatCents(entry.cents)} for ${entry.note}, shared by ${entry.sharedBy.join(', ')}`;
case 'owed':
return `${entry.from} owes ${entry.to} ${formatCents(entry.cents)} for ${entry.note}`;
case 'settled':
return `${entry.from} paid ${entry.to} back ${formatCents(entry.cents)}`;
default:
return assertNever(entry);
}
}
/** Add up every entry into one balance per person. */
export function balances(entries: readonly Entry[]): Balances {
const result: Balances = {};
const add = (person: string, cents: number): void => {
result[person] = (result[person] ?? 0) + cents;
};
for (const entry of entries) {
switch (entry.kind) {
case 'paid': {
add(entry.payer, entry.cents);
const shares = splitCents(entry.cents, entry.sharedBy);
entry.sharedBy.forEach((person, i) => add(person, -shares[i]));
break;
}
case 'owed':
add(entry.from, -entry.cents);
add(entry.to, entry.cents);
break;
case 'settled':
add(entry.from, entry.cents);
add(entry.to, -entry.cents);
break;
default:
assertNever(entry);
}
}
return result;
}
/** One line per person, in alphabetical order. */
export function balanceLines(totals: Balances): string[] {
return Object.keys(totals)
.sort()
.map((person) => {
const cents = totals[person];
if (cents > 0) return `${person} gets back ${formatCents(cents)}`;
if (cents < 0) return `${person} owes ${formatCents(-cents)}`;
return `${person} is even`;
});
}
/** The largest debt is paid to the largest creditor first, until everyone is even. */
export function settleUp(totals: Balances): Transfer[] {
const byAmount = (a: {person: string; cents: number}, b: {person: string; cents: number}): number =>
b.cents - a.cents || (a.person < b.person ? -1 : 1);
const people = Object.keys(totals);
const debtors = people
.filter((person) => totals[person] < 0)
.map((person) => ({person, cents: -totals[person]}))
.sort(byAmount);
const creditors = people
.filter((person) => totals[person] > 0)
.map((person) => ({person, cents: totals[person]}))
.sort(byAmount);
const transfers: Transfer[] = [];
let d = 0;
let c = 0;
while (d < debtors.length && c < creditors.length) {
const debtor = debtors[d];
const creditor = creditors[c];
const cents = Math.min(debtor.cents, creditor.cents);
transfers.push({from: debtor.person, to: creditor.person, cents});
debtor.cents -= cents;
creditor.cents -= cents;
if (debtor.cents === 0) d += 1;
if (creditor.cents === 0) c += 1;
}
return transfers;
}
/** Everything the program prints for a list of entries. */
export function report(entries: readonly Entry[]): string[] {
const totals = balances(entries);
return [
'Entries:',
...entries.map((entry) => ` ${describeEntry(entry)}`),
'Balances:',
...balanceLines(totals).map((line) => ` ${line}`),
'To settle up:',
...settleUp(totals).map((t) => ` ${t.from} pays ${t.to} ${formatCents(t.cents)}`)
];
}
for (const line of report(trip)) console.log(line);
trip.ts
// The fixture: a weekend trip of three friends, in whole cents.
import type {Entry} from './main.ts';
export const trip: readonly Entry[] = [
{kind: 'paid', payer: 'Ana', cents: 9000, sharedBy: ['Ana', 'Ben', 'Cleo'], note: 'dinner'},
{kind: 'paid', payer: 'Ben', cents: 4500, sharedBy: ['Ben', 'Cleo'], note: 'museum tickets'},
{kind: 'paid', payer: 'Cleo', cents: 1000, sharedBy: ['Ana', 'Ben', 'Cleo'], note: 'coffee'},
{kind: 'owed', from: 'Cleo', to: 'Ana', cents: 1200, note: 'the train'},
{kind: 'settled', from: 'Ben', to: 'Ana', cents: 1000}
];
checks.ts
// Compile-time checks: tsc type-checks this file, nothing runs it.
import type {Entry} from './main.ts';
import {assertNever, describeEntry} from './main.ts';
// 1. A kind that is not part of Entry is rejected.
// @ts-expect-error: 'refunded' is not a kind of Entry
const unknownKind: Entry = {kind: 'refunded', from: 'Ana', to: 'Ben', cents: 500};
// 2. A new variant that a switch does not handle fails to compile.
type Refunded = {readonly kind: 'refunded'; readonly from: string; readonly to: string; readonly cents: number};
export function describeWithRefunds(entry: Entry | Refunded): string {
switch (entry.kind) {
case 'paid':
case 'owed':
case 'settled':
return describeEntry(entry);
default:
// @ts-expect-error: 'refunded' is not handled, so entry is a Refunded here, not never
return assertNever(entry);
}
}
export {unknownKind};
Take it further
- Add the Refunded kind from checks.ts to Entry for real: follow the errors tsc now reports in describeEntry and balances, handle the new kind, and delete the line in checks.ts that tsc now says is unused.
- Let a paid entry split by shares instead of equally (for example {person: 'Ana', shares: 2}), still in whole cents that add up to the amount.
- Write formatCents for negative amounts as well (-0.83), and test it with -5 and -100.
- Add a second fixture, a flat with four people and a month of shopping, and print both reports.
- Give every entry a date and print the entries in date order.
Projects are practice: your checks run in your browser or on your computer and never count toward a certificate.
Sources
- Narrowing: discriminated unions and exhaustiveness checking (The TypeScript Handbook)
- TypeScript 3.9 release notes: // @ts-expect-error comments (TypeScript documentation)
- Object Types: readonly properties and index signatures (The TypeScript Handbook)
- Everyday Types: union types (The TypeScript Handbook)
- TSConfig reference: strict
Last reviewed October 4, 2026
Back to the TypeScript course