// Projekt Einstieg · etwa 3 Stunden Arbeit
Typisierter Ausgabenteiler
Sie schreiben ein kleines, striktes TypeScript-Programm für eine Gruppenreise. Die Einträge stehen als typisiertes Array-Literal in trip.ts: Jemand hat ein Abendessen bezahlt, das sich drei Personen teilen, jemand schuldet jemandem Geld für ein Ticket, jemand hat eine Schuld zurückgezahlt. Eine Discriminated Union modelliert die drei Arten paid, owed und settled, sodass jeder switch über einen Eintrag alle behandeln muss: Fügen Sie eine vierte Art hinzu, ohne sie zu behandeln, hält tsc Sie auf, und checks.ts beweist das mit @ts-expect-error. Das Programm rechnet alles zu einem Saldo pro Person zusammen und gibt aus, wer wem wie viel zahlt. Beträge sind ganze Cent in einer number, die immer ganzzahlig ist, denn 0.1 + 0.2 ist als Gleitkommazahl nicht genau 0.3, 10 + 20 Cent aber genau 30; lässt sich ein Betrag nicht gleichmäßig teilen, tragen die ersten Personen der Liste die übrigen Cent. Sie nutzen die Einsteigerstufe: Annotationen, Objekttypen und Aliase, Literaltypen, Unions und Narrowing, Discriminated Unions, never, readonly, Index-Signaturen und Arrays.
Was das fertige Programm kann
- npx tsc läuft mit der tsconfig des Kurses (strict an, noEmit) ohne Fehler durch, ohne any und ohne Typ-Assertions, und node main.ts gibt den Bericht für die Reise in trip.ts aus.
- Entry ist eine Discriminated Union aus drei Objekttypen mit readonly-Eigenschaften und einer Eigenschaft kind: paid (payer, cents, sharedBy, note), owed (from schuldet to: from, to, cents, note) und settled (from hat to zurückgezahlt: from, to, cents).
- Jeder Betrag ist in ganzen Cent angegeben (eine ganzzahlige number). splitCents(cents, people) teilt einen Betrag gleichmäßig; geht die Teilung nicht auf, zahlen die ersten Personen der Liste je einen Cent mehr, sodass die Anteile immer genau den Betrag ergeben. Bei einer leeren Liste und bei cents, die keine ganze Zahl über 0 sind (Number.isInteger), wirft die Funktion einen RangeError.
- describeEntry(entry) gibt pro Art eine Zeile zurück, mit einem switch über entry.kind und return assertNever(entry) im default-Zweig: 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) gibt ein Objekt mit Index-Signatur zurück, {[person: string]: number}: Ein Eintrag paid schreibt den Betrag dem payer gut und zieht jeden Anteil bei den beteiligten Personen ab; owed zieht den Betrag bei from ab und schreibt ihn to gut; settled macht das Umgekehrte. Die Salden jeder Liste von Einträgen ergeben zusammen 0.
- balanceLines(totals) gibt eine Zeile pro Person in alphabetischer Reihenfolge zurück: Ana gets back 58.66, Ben owes 0.83 oder Dev is even.
- settleUp(totals) gibt die Überweisungen zurück, nach denen alle ausgeglichen sind: Der größte Schuldner zahlt dem größten Gläubiger so viel, wie beide zulassen, und so weiter (bei Gleichstand nach Name). Eine schon ausgeglichene Gruppe bekommt keine Überweisungen.
- checks.ts, das tsc prüft, aber nichts ausführt, enthält zwei Zeilen mit @ts-expect-error: einen Entry mit unbekannter Art und einen switch über Entry plus einen neuen Typ Refunded, der refunded nicht behandelt. Wäre eine der Zeilen kein Fehler mehr, meldete tsc error TS2578: Unused '@ts-expect-error' directive.
Aufbau der Startdateien
- main.ts
- Ihre Arbeit: die Union Entry, formatCents, splitCents, describeEntry, balances, balanceLines und settleUp. Paid, Balances, Transfer, assertNever und report sind vorgegeben; die letzte Zeile gibt den Bericht für die Reise aus.
- trip.ts
- Die Testdaten: die fünf Einträge der Reise als typisiertes Array-Literal, readonly Entry[]. Vorgegeben; die Datei importiert Entry mit import type.
- checks.ts
- Der Beweis zur Übersetzungszeit mit zwei Zeilen @ts-expect-error. Vorgegeben; tsc prüft die Datei, nichts führt sie aus. Lesen Sie sie, und ändern Sie sie nicht.
- main.test.ts
- Die Abnahmetests (node:test). Nicht typgeprüft (die tsconfig schließt *.test.ts aus). Im Starter-Download.
- tsconfig.json
- Die tsconfig des Kurses: strict, noEmit, erasableSyntaxOnly, verbatimModuleSyntax, allowImportingTsExtensions. Im Starter-Download.
- package.json
- {"type": "module"}, damit Node die .ts-Dateien als ES-Module behandelt. Im Starter-Download.
- learnrun.js
- Die Hilfsdatei des Kurses mit runMain(), mit der der letzte Test main.ts ausführt. Im Starter-Download.
Etappen
Etappe 1
Geld in ganzen Cent
Schreiben Sie formatCents mit Math.floor(cents / 100) und String(cents % 100).padStart(2, '0'). Schreiben Sie splitCents: Prüfen Sie zuerst die Eingabe und werfen Sie einen RangeError, geben Sie dann allen Math.floor(cents / people.length) und den ersten cents % people.length Personen einen Cent mehr. Nur ganzzahlige Rechnung: Math.floor hält jeden Anteil bei ganzen Cent.
Prüfungen, die nach dieser Etappe bestehen:
- formatCents schreibt ganze Cent als Euro und Cent
- splitCents teilt einen Betrag exakt, die ersten Personen übernehmen die übrigen Cent
- splitCents lehnt eine leere Personenliste und Beträge ab, die keine ganzen Cent über 0 sind
Etappe 2
Die Discriminated Union
Schreiben Sie Owed und Settled neben Paid, jeweils mit eigenem Literal für kind, und setzen Sie Entry = Paid | Owed | Settled. Jetzt besteht trip.ts die Typprüfung. Schreiben Sie describeEntry als switch über entry.kind; in jedem case hat tsc entry eingeengt, sodass entry.payer nur unter paid erlaubt ist. Zum Schluss default: return assertNever(entry). Jetzt besteht auch checks.ts die Typprüfung, und npx tsc gibt nichts aus.
Prüfungen, die nach dieser Etappe bestehen:
- describeEntry schreibt für jede Art von Eintrag eine Zeile
Etappe 3
Salden pro Person
Schreiben Sie balances mit const result: Balances = {} und einer kleinen Hilfsfunktion add(person, cents), die eine fehlende Person mit ?? bei 0 beginnen lässt. Verzweigen Sie wieder mit switch über entry.kind, mit assertNever im default; ein Eintrag paid nutzt splitCents, sodass die Anteile centgenau den Betrag ergeben.
Prüfungen, die nach dieser Etappe bestehen:
- balances verrechnet Einträge paid, owed und settled
- Die Salden der Reise ergeben zusammen null
Etappe 4
Wer schuldet wem
Schreiben Sie balanceLines mit Object.keys(totals).sort(). Schreiben Sie settleUp: eine Liste der Schuldner (Saldo unter 0, Betrag positiv gemacht) und eine der Gläubiger, beide absteigend sortiert und bei Gleichstand nach Name. Gehen Sie mit zwei Indizes durch beide Listen und fügen Sie {from, to, cents} mit dem kleineren der beiden Beträge hinzu. Führen Sie dann npx tsc und node main.ts aus.
Prüfungen, die nach dieser Etappe bestehen:
- balanceLines sagt, wer Geld zurückbekommt, wer schuldet und wer ausgeglichen ist
- settleUp zahlt die größte Schuld zuerst an den größten Gläubiger
- Das Programm gibt die Einträge, Salden und Überweisungen der Reise aus
Im Browser bauen
Bearbeiten Sie unten main.ts; die anderen Startdateien bleiben, wie sie sind. „Code prüfen“ führt die Typprüfung und alle Abnahmetests aus, rechnen Sie also bis zum letzten Meilenstein mit Fehlschlägen.
Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.
Beim ersten Ausführen lädt Ihr Browser den TypeScript-Compiler herunter (bis zu 2.1 MB) und speichert ihn im Cache. Ihr Code bleibt auf Ihrem Gerät.
Auf dem eigenen Rechner bauen
Legen Sie einen Ordner mit diesen Startdateien an, installieren Sie TypeScript 7.0 und arbeiten Sie die Etappen ab. Die Abnahmetests starten Sie jederzeit mit:
Startdateien als eine .zip herunterladen (Startdateien, main.test.ts, package.json, tsconfig.json und learnrun.js)npx tsc
node --testnpx tsc braucht TypeScript im Ordner: Führen Sie dort einmal npm install --save-dev typescript aus. tsc prüft die Typen; Node.js führt main.ts aus, indem es sie entfernt.
learnrun.js herunterladenmain.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"
]
}
Abnahmetests
Das Projekt ist fertig, wenn die Typprüfung und jede Prüfung in main.test.ts bestehen. Lesen Sie sie vor dem Start: Sie sind die Spezifikation, als Code geschrieben.
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 schreibt ganze Cent als Euro und Cent', () => {
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 teilt einen Betrag exakt, die ersten Personen übernehmen die übrigen Cent', () => {
assert.deepEqual(splitCents(1000, ['Ana', 'Ben', 'Cleo']), [334, 333, 333], 'splitCents(1000, drei Personen)');
assert.deepEqual(splitCents(4500, ['Ben', 'Cleo']), [2250, 2250], 'splitCents(4500, zwei Personen)');
assert.deepEqual(splitCents(2, ['Ana', 'Ben', 'Cleo']), [1, 1, 0], 'splitCents(2, drei Personen)');
});
test('splitCents lehnt eine leere Personenliste und Beträge ab, die keine ganzen Cent über 0 sind', () => {
assert.throws(() => splitCents(1000, []), RangeError, 'splitCents(1000, []) sollte einen RangeError werfen');
assert.throws(() => splitCents(0, ['Ana']), RangeError, 'splitCents(0, ...) sollte einen RangeError werfen');
assert.throws(() => splitCents(12.5, ['Ana']), RangeError, 'splitCents(12.5, ...) sollte einen RangeError werfen');
});
test('describeEntry schreibt für jede Art von Eintrag eine Zeile', () => {
assert.equal(describeEntry(lunch[0]), 'Ana paid 30.00 for lunch, shared by Ana, Ben', 'der Eintrag paid');
assert.equal(describeEntry(lunch[1]), 'Ana owes Dev 4.00 for a coffee', 'der Eintrag owed');
assert.equal(describeEntry(lunch[2]), 'Ben paid Ana back 5.00', 'der Eintrag settled');
});
test('balances verrechnet Einträge paid, owed und settled', () => {
assert.deepEqual(balances(lunch), {Ana: 600, Ben: -1000, Dev: 400}, 'Salden der Einträge von lunch');
assert.deepEqual(balances([]), {}, 'Salden ohne Einträge');
});
test('Die Salden der Reise ergeben zusammen null', () => {
const totals = balances(trip);
assert.deepEqual(totals, {Ana: 5866, Ben: -83, Cleo: -5783}, 'Salden der Reise');
const sum = Object.values(totals).reduce((a, b) => a + b, 0);
assert.equal(sum, 0, 'die Salden jeder Gruppe müssen zusammen 0 Cent ergeben');
});
test('balanceLines sagt, wer Geld zurückbekommt, wer schuldet und wer ausgeglichen ist', () => {
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'],
'eine Zeile pro Person, alphabetisch geordnet'
);
});
test('settleUp zahlt die größte Schuld zuerst an den größten Gläubiger', () => {
assert.deepEqual(
settleUp({Ana: 5866, Ben: -83, Cleo: -5783}),
[
{from: 'Cleo', to: 'Ana', cents: 5783},
{from: 'Ben', to: 'Ana', cents: 83}
],
'die Überweisungen für die Reise'
);
assert.deepEqual(
settleUp({Ana: -700, Ben: 300, Cleo: 400}),
[
{from: 'Ana', to: 'Cleo', cents: 400},
{from: 'Ana', to: 'Ben', cents: 300}
],
'ein Schuldner und zwei Gläubiger'
);
assert.deepEqual(settleUp({Ana: 0, Ben: 0}), [], 'eine schon ausgeglichene Gruppe braucht keine Überweisungen');
});
test('Das Programm gibt die Einträge, Salden und Überweisungen der Reise aus', 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, `das Programm gab aus:\n${printed.join('\n')}`);
});
Das fertige Programm starten
npx tsc
node main.tsReferenzlösung
Versuchen Sie zuerst die Etappen. Diese Lösung besteht alle Abnahmetests und die Typprüfung.
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};
Weiterentwickeln
- Nehmen Sie die Art Refunded aus checks.ts wirklich in Entry auf: Folgen Sie den Fehlern, die tsc nun in describeEntry und balances meldet, behandeln Sie die neue Art und löschen Sie die Zeile in checks.ts, die tsc jetzt als unbenutzt meldet.
- Lassen Sie einen Eintrag paid nach Anteilen statt gleichmäßig teilen (zum Beispiel {person: 'Ana', shares: 2}), weiterhin in ganzen Cent, die zusammen den Betrag ergeben.
- Schreiben Sie formatCents auch für negative Beträge (-0.83) und testen Sie es mit -5 und -100.
- Fügen Sie zweite Testdaten hinzu, eine WG mit vier Personen und einem Monat Einkäufe, und geben Sie beide Berichte aus.
- Geben Sie jedem Eintrag ein Datum und geben Sie die Einträge nach Datum geordnet aus.
Projekte sind Übung: Ihre Prüfungen laufen im Browser oder auf Ihrem Rechner und zählen nie für eine Bescheinigung.
Quellen
- Narrowing: discriminated unions and exhaustiveness checking (The TypeScript Handbook); englisch
- TypeScript 3.9 release notes: // @ts-expect-error comments (TypeScript documentation); englisch
- Object Types: readonly properties and index signatures (The TypeScript Handbook); englisch
- Everyday Types: union types (The TypeScript Handbook); englisch
- TSConfig reference: strict; englisch
Zuletzt geprüft am 4. Oktober 2026
Zurück zum Kurs TypeScript