Project: Train Ticket Booking
In this project you will build the core of a train ticket booking system, the kind of logic that sits behind a railway reservation website. Passengers search for trains, book seats in a class such as 3A, get a PNR number, see whether each seat is confirmed or on the waiting list, and can cancel for a refund.
The project uses almost everything from this course: literal types and as const, template literal types, interfaces, discriminated unions, unknown with type guards, generics, utility types, a class with private fields, and exhaustive switch checks. The whole program is one file of erasable TypeScript, so it runs in the browser editor and with node booking.ts. The fares and rules are made up for practice; they are not real railway fares.
What we are building
The finished program will:
- Store trains with their route, distance and number of seats per class.
- Search trains between two stations.
- Accept booking requests as untrusted data (as if they came from a web form), validate them, and return either a booking or a clear error.
- Give each passenger a confirmed seat while seats last, then a waiting-list number.
- Calculate fares, with half fare for children under 12.
- Cancel a booking and calculate a refund.
- Print tickets in a neat, aligned format.
We will build it in six steps. Steps 1 to 5 show the code for each part with an explanation, and step 6 puts everything together into a program you can run and change.
Step 1: model the data with types
Good TypeScript programs start with the types. They describe the data before any logic is written, and every function that follows is checked against them.
const CLASSES = ["SL", "3A", "2A"] as const;
type TravelClass = (typeof CLASSES)[number];
type Pnr = `PNR${number}`;
interface Train {
number: number;
name: string;
from: string;
to: string;
distanceKm: number;
seats: Record<TravelClass, number>;
}
type Passenger = { name: string; age: number };
type SeatStatus =
| { kind: "confirmed"; coach: string; seat: number }
| { kind: "waiting"; position: number };
type Booking = {
readonly pnr: Pnr;
trainNumber: number;
travelClass: TravelClass;
passengers: Passenger[];
statuses: SeatStatus[];
fare: number;
cancelled: boolean;
};
type BookingRequest = Omit<Booking, "pnr" | "statuses" | "fare" | "cancelled">;
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function ok<T>(value: T): Result<T> {
return { ok: true, value };
}
function fail<T>(error: string): Result<T> {
return { ok: false, error };
}Every type here was chosen for a reason:
CLASSESis anas constarray, andTravelClassis derived from it with(typeof CLASSES)[number], giving"SL" | "3A" | "2A". The list exists at run time (for validation and messages) and as a type, from one source.Pnris a template literal type: only strings like"PNR4521890"fit, so a plain name or a number cannot be passed where a PNR is expected.seats: Record<TravelClass, number>forces every train to state the seats for every class. If you add"1A"toCLASSES, every train definition shows an error until it is updated.SeatStatusis a discriminated union. A confirmed seat has a coach and seat number; a waiting-list entry has a position. It is impossible to create a "confirmed" status without a seat.readonly pnrmeans a booking's PNR can never change after it is created.BookingRequestis derived withOmit: the request has everything a booking has except the fields the system fills in.Result<T>is a generic discriminated union for operations that can fail. Instead of throwing errors, functions return{ ok: true, value }or{ ok: false, error }, and the caller must checkokbefore touchingvalue. The small helpersok()andfail()build these objects.
Step 2: calculate fares
The fare depends on the distance, the class and the passengers' ages. Here is the fare logic on its own, with just enough types around it to run:
const CLASSES = ["SL", "3A", "2A"] as const;
type TravelClass = (typeof CLASSES)[number];
type Train = { number: number; name: string; distanceKm: number };
type Passenger = { name: string; age: number };
const RATE_PER_KM = { SL: 0.5, "3A": 1.3, "2A": 1.9 } satisfies Record<TravelClass, number>;
const COACH_PREFIX: Record<TravelClass, string> = { SL: "S", "3A": "B", "2A": "A" };
function fareFor(train: Train, travelClass: TravelClass, passengers: readonly Passenger[]): number {
const adultFare = Math.round(train.distanceKm * RATE_PER_KM[travelClass]);
return passengers.reduce((sum, p) => sum + (p.age < 12 ? Math.round(adultFare / 2) : adultFare), 0);
}
const rajdhani: Train = { number: 12951, name: "Rajdhani Express", distanceKm: 1386 };
const family: Passenger[] = [{ name: "Priya", age: 34 }, { name: "Aarav", age: 8 }];
for (const c of CLASSES) {
console.log(c, fareFor(rajdhani, c, family));
}SL 1040 3A 2703 2A 3950
RATE_PER_KM uses satisfies Record<TravelClass, number>. The compiler checks that every class has a rate and that there are no typos in the keys, but the object keeps its precise type. The readonly Passenger[] parameter promises that fareFor will not change the caller's list. For the family of two, the child pays half of the adult fare: in 3A, 1,802 plus 901 makes 2,703.
In the full program, a second lookup table, COACH_PREFIX, maps each class to the letter used in coach names (S1, B1, A1), again checked by Record<TravelClass, string>.
Step 3: validate untrusted input
A booking request arrives from outside the program, perhaps as JSON from a form. Its type is honestly unknown. Before the system trusts it, every field must be checked, and the result must say exactly what was wrong.
function isTravelClass(value: unknown): value is TravelClass {
return typeof value === "string" && (CLASSES as readonly string[]).includes(value);
}
function isPassenger(value: unknown): value is Passenger {
return (
typeof value === "object" && value !== null &&
"name" in value && typeof value.name === "string" && value.name.trim() !== "" &&
"age" in value && typeof value.age === "number" && value.age > 0 && value.age < 120
);
}
function parseRequest(input: unknown): Result<BookingRequest> {
if (typeof input !== "object" || input === null) return fail("Request must be an object");
if (!("trainNumber" in input) || typeof input.trainNumber !== "number") return fail("trainNumber must be a number");
if (!("travelClass" in input) || !isTravelClass(input.travelClass)) return fail(`travelClass must be one of ${CLASSES.join(", ")}`);
if (!("passengers" in input) || !Array.isArray(input.passengers) || input.passengers.length === 0) {
return fail("At least one passenger is required");
}
if (input.passengers.length > 6) return fail("At most 6 passengers per booking");
if (!input.passengers.every(isPassenger)) return fail("Every passenger needs a name and a valid age");
return ok({ trainNumber: input.trainNumber, travelClass: input.travelClass, passengers: input.passengers });
}How the checks narrow the type, step by step:
- After
typeof input !== "object" || input === nullreturns early, TypeScript knowsinputis a non-null object. "trainNumber" in inputtells TypeScript the property exists, andtypeof input.trainNumber !== "number"narrows it to a number in the code that follows.isTravelClassis a type guard. After it passes,input.travelClasshas the typeTravelClass, not justunknown.input.passengers.every(isPassenger)uses a type guard inside an array method, so afterwards the array is known to bePassenger[].- At the end, the object passed to
ok(...)is fully checked, and TypeScript confirms it matchesBookingRequest.
Notice that no type assertion (as) is used anywhere. Every type is proven by a real run-time check, which is exactly what you want at the boundary of a program.
Step 4: the booking service class
The BookingService class holds the trains and bookings and enforces the rules. All its state is in #private fields, so other code can only change it through the public methods.
class BookingService {
#trains = new Map<number, Train>();
#bookings = new Map<Pnr, Booking>();
#nextSeat = new Map<string, number>(); // "12951-3A" -> next free seat number
#waiting = new Map<string, number>(); // "12951-3A" -> waiting list length
#nextPnr = 4521890;
addTrain(train: Train): void {
this.#trains.set(train.number, train);
}
search(from: string, to: string): Train[] {
const match = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
return [...this.#trains.values()].filter((t) => match(t.from, from) && match(t.to, to));
}
book(input: unknown): Result<Booking> {
const parsed = parseRequest(input);
if (!parsed.ok) return parsed;
const request = parsed.value;
const train = this.#trains.get(request.trainNumber);
if (!train) return fail(`No train with number ${request.trainNumber}`);
const key = `${train.number}-${request.travelClass}`;
const statuses: SeatStatus[] = request.passengers.map(() => {
const seat = this.#nextSeat.get(key) ?? 1;
if (seat <= train.seats[request.travelClass]) {
this.#nextSeat.set(key, seat + 1);
return { kind: "confirmed", coach: `${COACH_PREFIX[request.travelClass]}1`, seat };
}
const position = (this.#waiting.get(key) ?? 0) + 1;
this.#waiting.set(key, position);
return { kind: "waiting", position };
});
const booking: Booking = {
...request,
pnr: `PNR${this.#nextPnr++}`,
statuses,
fare: fareFor(train, request.travelClass, request.passengers),
cancelled: false,
};
this.#bookings.set(booking.pnr, booking);
return ok(booking);
}
cancel(pnr: Pnr): Result<number> {
const booking = this.#bookings.get(pnr);
if (!booking) return fail(`${pnr} not found`);
if (booking.cancelled) return fail(`${pnr} is already cancelled`);
booking.cancelled = true;
return ok(Math.round(booking.fare * 0.75)); // refund: 75% in this demo
}
find(pnr: Pnr): Booking | undefined {
return this.#bookings.get(pnr);
}
}Points worth studying:
- Typed maps.
new Map<Pnr, Booking>()means you can only store bookings under valid PNRs, andgetreturnsBooking | undefined, so the code must handle a missing booking. - Returning early with a Result. In
book, the lineif (!parsed.ok) return parsed;returns the failed result directly. Inside theif, TypeScript has narrowedparsedto the{ ok: false; error: string }member, which also fitsResult<Booking>, so no conversion is needed. After theif,parsed.valueis safe to use. - Seat allocation. The service remembers the next free seat and the waiting-list length for each train and class, using keys such as
"12951-3A". The callback inmapreturns object literals, and the annotationSeatStatus[]makes TypeScript check each one against the union, including the literalkindvalues. - Template literal values.
pnr: `PNR${this.#nextPnr++}`produces a string that TypeScript accepts as aPnr, because it can see the pattern matches. - readonly in practice.
cancelchangesbooking.cancelled, which is allowed, but writingbooking.pnr = ...would be a compile error.
Step 5: print tickets with an exhaustive switch
The last piece turns a booking into readable lines. statusText handles each kind of seat status:
function statusText(status: SeatStatus): string {
switch (status.kind) {
case "confirmed":
return `CNF ${status.coach}/${status.seat}`;
case "waiting":
return `WL ${status.position}`;
default: {
const unhandled: never = status;
return unhandled;
}
}
}
function printTicket(b: Booking): void {
console.log(`${b.pnr} Train ${b.trainNumber} Class ${b.travelClass} Fare ₹${b.fare}${b.cancelled ? " CANCELLED" : ""}`);
b.passengers.forEach((p, i) => {
const status = b.statuses[i];
console.log(` ${p.name.padEnd(8)} ${String(p.age).padStart(3)} ${status ? statusText(status) : "?"}`);
});
}The default branch is the exhaustiveness check from Unions & Narrowing. Today it can never run. But if someone adds a new kind of status, such as RAC (reservation against cancellation), the assignment to never stops the build until statusText handles it. In printTicket, b.statuses[i] might be undefined as far as the compiler knows (an array index can be out of range), so the code checks it before calling statusText. That check is required when noUncheckedIndexedAccess is on.
Step 6: the complete program
Here is everything together, with a short test at the end that adds two trains, searches, makes five booking attempts (two valid, three with different problems), cancels one booking and tries to cancel it again. Run it, then change the requests and run it again.
// ---------- Step 1: types ----------
const CLASSES = ["SL", "3A", "2A"] as const;
type TravelClass = (typeof CLASSES)[number];
type Pnr = `PNR${number}`;
interface Train {
number: number;
name: string;
from: string;
to: string;
distanceKm: number;
seats: Record<TravelClass, number>;
}
type Passenger = { name: string; age: number };
type SeatStatus =
| { kind: "confirmed"; coach: string; seat: number }
| { kind: "waiting"; position: number };
type Booking = {
readonly pnr: Pnr;
trainNumber: number;
travelClass: TravelClass;
passengers: Passenger[];
statuses: SeatStatus[];
fare: number;
cancelled: boolean;
};
type BookingRequest = Omit<Booking, "pnr" | "statuses" | "fare" | "cancelled">;
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function ok<T>(value: T): Result<T> {
return { ok: true, value };
}
function fail<T>(error: string): Result<T> {
return { ok: false, error };
}
// ---------- Step 2: fares ----------
const RATE_PER_KM = { SL: 0.5, "3A": 1.3, "2A": 1.9 } satisfies Record<TravelClass, number>;
const COACH_PREFIX: Record<TravelClass, string> = { SL: "S", "3A": "B", "2A": "A" };
function fareFor(train: Train, travelClass: TravelClass, passengers: readonly Passenger[]): number {
const adultFare = Math.round(train.distanceKm * RATE_PER_KM[travelClass]);
return passengers.reduce((sum, p) => sum + (p.age < 12 ? Math.round(adultFare / 2) : adultFare), 0);
}
// ---------- Step 3: validating input ----------
function isTravelClass(value: unknown): value is TravelClass {
return typeof value === "string" && (CLASSES as readonly string[]).includes(value);
}
function isPassenger(value: unknown): value is Passenger {
return (
typeof value === "object" && value !== null &&
"name" in value && typeof value.name === "string" && value.name.trim() !== "" &&
"age" in value && typeof value.age === "number" && value.age > 0 && value.age < 120
);
}
function parseRequest(input: unknown): Result<BookingRequest> {
if (typeof input !== "object" || input === null) return fail("Request must be an object");
if (!("trainNumber" in input) || typeof input.trainNumber !== "number") return fail("trainNumber must be a number");
if (!("travelClass" in input) || !isTravelClass(input.travelClass)) return fail(`travelClass must be one of ${CLASSES.join(", ")}`);
if (!("passengers" in input) || !Array.isArray(input.passengers) || input.passengers.length === 0) {
return fail("At least one passenger is required");
}
if (input.passengers.length > 6) return fail("At most 6 passengers per booking");
if (!input.passengers.every(isPassenger)) return fail("Every passenger needs a name and a valid age");
return ok({ trainNumber: input.trainNumber, travelClass: input.travelClass, passengers: input.passengers });
}
// ---------- Step 4: the booking service ----------
class BookingService {
#trains = new Map<number, Train>();
#bookings = new Map<Pnr, Booking>();
#nextSeat = new Map<string, number>(); // "12951-3A" -> next free seat number
#waiting = new Map<string, number>(); // "12951-3A" -> waiting list length
#nextPnr = 4521890;
addTrain(train: Train): void {
this.#trains.set(train.number, train);
}
search(from: string, to: string): Train[] {
const match = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
return [...this.#trains.values()].filter((t) => match(t.from, from) && match(t.to, to));
}
book(input: unknown): Result<Booking> {
const parsed = parseRequest(input);
if (!parsed.ok) return parsed;
const request = parsed.value;
const train = this.#trains.get(request.trainNumber);
if (!train) return fail(`No train with number ${request.trainNumber}`);
const key = `${train.number}-${request.travelClass}`;
const statuses: SeatStatus[] = request.passengers.map(() => {
const seat = this.#nextSeat.get(key) ?? 1;
if (seat <= train.seats[request.travelClass]) {
this.#nextSeat.set(key, seat + 1);
return { kind: "confirmed", coach: `${COACH_PREFIX[request.travelClass]}1`, seat };
}
const position = (this.#waiting.get(key) ?? 0) + 1;
this.#waiting.set(key, position);
return { kind: "waiting", position };
});
const booking: Booking = {
...request,
pnr: `PNR${this.#nextPnr++}`,
statuses,
fare: fareFor(train, request.travelClass, request.passengers),
cancelled: false,
};
this.#bookings.set(booking.pnr, booking);
return ok(booking);
}
cancel(pnr: Pnr): Result<number> {
const booking = this.#bookings.get(pnr);
if (!booking) return fail(`${pnr} not found`);
if (booking.cancelled) return fail(`${pnr} is already cancelled`);
booking.cancelled = true;
return ok(Math.round(booking.fare * 0.75)); // refund: 75% in this demo
}
find(pnr: Pnr): Booking | undefined {
return this.#bookings.get(pnr);
}
}
// ---------- Step 5: printing tickets ----------
function statusText(status: SeatStatus): string {
switch (status.kind) {
case "confirmed":
return `CNF ${status.coach}/${status.seat}`;
case "waiting":
return `WL ${status.position}`;
default: {
const unhandled: never = status;
return unhandled;
}
}
}
function printTicket(b: Booking): void {
console.log(`${b.pnr} Train ${b.trainNumber} Class ${b.travelClass} Fare ₹${b.fare}${b.cancelled ? " CANCELLED" : ""}`);
b.passengers.forEach((p, i) => {
const status = b.statuses[i];
console.log(` ${p.name.padEnd(8)} ${String(p.age).padStart(3)} ${status ? statusText(status) : "?"}`);
});
}
// ---------- Step 6: try it ----------
const railway = new BookingService();
railway.addTrain({
number: 12951, name: "Rajdhani Express", from: "Mumbai", to: "New Delhi",
distanceKm: 1386, seats: { SL: 0, "3A": 3, "2A": 2 },
});
railway.addTrain({
number: 12015, name: "Shatabdi Express", from: "New Delhi", to: "Ajmer",
distanceKm: 444, seats: { SL: 0, "3A": 2, "2A": 0 },
});
for (const t of railway.search("mumbai", "new delhi")) {
console.log(`Found ${t.number} ${t.name}, ${t.distanceKm} km`);
}
const requests: unknown[] = [
{ trainNumber: 12951, travelClass: "3A", passengers: [{ name: "Priya", age: 34 }, { name: "Aarav", age: 8 }] },
{ trainNumber: 12951, travelClass: "3A", passengers: [{ name: "Kabir", age: 61 }, { name: "Meera", age: 58 }] },
{ trainNumber: 12951, travelClass: "1A", passengers: [{ name: "Rohan", age: 25 }] },
{ trainNumber: 12951, travelClass: "2A", passengers: [] },
{ trainNumber: 22222, travelClass: "2A", passengers: [{ name: "Isha", age: 29 }] },
];
for (const request of requests) {
const result = railway.book(request);
if (result.ok) printTicket(result.value);
else console.log(`Booking failed: ${result.error}`);
}
const refund = railway.cancel("PNR4521890");
console.log(refund.ok ? `Refund: ₹${refund.value}` : refund.error);
console.log(railway.cancel("PNR4521890").ok);
const again = railway.find("PNR4521890");
if (again) printTicket(again);Found 12951 Rajdhani Express, 1386 km PNR4521890 Train 12951 Class 3A Fare ₹2703 Priya 34 CNF B1/1 Aarav 8 CNF B1/2 PNR4521891 Train 12951 Class 3A Fare ₹3604 Kabir 61 CNF B1/3 Meera 58 WL 1 Booking failed: travelClass must be one of SL, 3A, 2A Booking failed: At least one passenger is required Booking failed: No train with number 22222 Refund: ₹2027 false PNR4521890 Train 12951 Class 3A Fare ₹2703 CANCELLED Priya 34 CNF B1/1 Aarav 8 CNF B1/2
Read the output against the code. The 3A coach of train 12951 has three seats, so Priya, Aarav and Kabir are confirmed and Meera goes to the waiting list. The third request asks for class "1A", which isTravelClass rejects. The fourth has no passengers, and the fifth names a train that does not exist. The refund is 75 per cent of ₹2,703, rounded to ₹2,027, and the second cancellation fails because the booking is already cancelled, so ok is false.
Now try breaking it on purpose to see the compiler help you. In an editor with TypeScript (or by running tsc on the file), try these one at a time:
- Change a request to use
travelClass: "3a"in a typedBookingRequestinstead ofunknown. The compiler rejects the lower-case class. - Call
railway.cancel("4521890")without thePNRprefix. The template literal typePnrrejects it. - Add
"1A"toCLASSES. Errors appear inRATE_PER_KM,COACH_PREFIXand both train definitions, showing every place that needs the new class. - Remove the
case "waiting":branch fromstatusText. Thenevercheck reports the missing case.
Running it with Node.js
The program uses only erasable syntax: no enums, no parameter properties, no decorators. Save it as booking.ts and run node booking.ts with Node.js 22.18 or later. To check the types, create a tsconfig.json with "strict": true, "noUncheckedIndexedAccess": true and "erasableSyntaxOnly": true, and run npx tsc --noEmit. The program passes all of these checks.
If you want to go further, split it into modules: types.ts (Step 1), fares.ts, validate.ts, booking-service.ts and main.ts, importing types with import type as in Modules & Imports. Then wrap the service in an Express API as in TypeScript with Node.js, or build a booking form in React as in TypeScript with React.
Key points
- Start a TypeScript project by modelling the data: literal unions, discriminated unions and derived types.
- Derive types from one source (
as constarrays,Omit,Record) so changes spread automatically. - Treat outside input as
unknownand prove its type with type guards, not assertions. - A generic
Result<T>makes failures part of the type, so callers must handle them. - Private
#fields,readonlyand exhaustiveneverchecks protect the program's rules as it grows.
Exercise
Add RAC (reservation against cancellation) to the system. After the confirmed seats of a class are full, the next 2 passengers should get { kind: "rac"; number: n } (RAC 1, RAC 2) before anyone goes to the waiting list. Update the SeatStatus type, the allocation logic in book and statusText (which should print RAC 1). Notice which places the compiler points you to after changing the type.
Show solution
type SeatStatus =
| { kind: "confirmed"; coach: string; seat: number }
| { kind: "rac"; number: number }
| { kind: "waiting"; position: number };
const RAC_LIMIT = 2;
// inside BookingService: a new private field
// #rac = new Map<string, number>(); // "12951-3A" -> RAC places used
// inside book(), replacing the map callback:
// const statuses: SeatStatus[] = request.passengers.map(() => {
// const seat = this.#nextSeat.get(key) ?? 1;
// if (seat <= train.seats[request.travelClass]) {
// this.#nextSeat.set(key, seat + 1);
// return { kind: "confirmed", coach: `${COACH_PREFIX[request.travelClass]}1`, seat };
// }
// const racUsed = this.#rac.get(key) ?? 0;
// if (racUsed < RAC_LIMIT) {
// this.#rac.set(key, racUsed + 1);
// return { kind: "rac", number: racUsed + 1 };
// }
// const position = (this.#waiting.get(key) ?? 0) + 1;
// this.#waiting.set(key, position);
// return { kind: "waiting", position };
// });
function statusText(status: SeatStatus): string {
switch (status.kind) {
case "confirmed":
return `CNF ${status.coach}/${status.seat}`;
case "rac":
return `RAC ${status.number}`;
case "waiting":
return `WL ${status.position}`;
default: {
const unhandled: never = status;
return unhandled;
}
}
}
console.log(statusText({ kind: "rac", number: 1 })); // RAC 1As soon as you add the rac member to SeatStatus, the never check in statusText reports the unhandled case, so you cannot forget to print it. With the sample requests, Meera now gets RAC 1 instead of WL 1.
Quick Quiz
1. Why is TravelClass derived from the CLASSES array instead of written by hand?
Deriving the type from an as const array keeps the run-time list and the type in sync.
2. What does Result<T> force callers to do?
value only exists on the ok: true member, so narrowing is required.
3. Why does parseRequest take unknown rather than BookingRequest?
Outside data has no guaranteed shape; unknown forces validation.
4. What happens if you add a new member to SeatStatus but forget to update statusText?
The exhaustiveness check turns a forgotten case into a compile error.
Learn TypeScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
