Project: Train Ticket Booking

TypeScript · Lesson 28 of 28 10 min read Updated

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:

  1. Store trains with their route, distance and number of seats per class.
  2. Search trains between two stations.
  3. 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.
  4. Give each passenger a confirmed seat while seats last, then a waiting-list number.
  5. Calculate fares, with half fare for children under 12.
  6. Cancel a booking and calculate a refund.
  7. 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.

TypeScript
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:

  • CLASSES is an as const array, and TravelClass is 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.
  • Pnr is 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" to CLASSES, every train definition shows an error until it is updated.
  • SeatStatus is 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 pnr means a booking's PNR can never change after it is created.
  • BookingRequest is derived with Omit: 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 check ok before touching value. The small helpers ok() and fail() 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:

TypeScriptRun it yourself
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));
}
Output
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.

TypeScript
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 === null returns early, TypeScript knows input is a non-null object.
  • "trainNumber" in input tells TypeScript the property exists, and typeof input.trainNumber !== "number" narrows it to a number in the code that follows.
  • isTravelClass is a type guard. After it passes, input.travelClass has the type TravelClass, not just unknown.
  • input.passengers.every(isPassenger) uses a type guard inside an array method, so afterwards the array is known to be Passenger[].
  • At the end, the object passed to ok(...) is fully checked, and TypeScript confirms it matches BookingRequest.

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.

TypeScript
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, and get returns Booking | undefined, so the code must handle a missing booking.
  • Returning early with a Result. In book, the line if (!parsed.ok) return parsed; returns the failed result directly. Inside the if, TypeScript has narrowed parsed to the { ok: false; error: string } member, which also fits Result<Booking>, so no conversion is needed. After the if, parsed.value is 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 in map returns object literals, and the annotation SeatStatus[] makes TypeScript check each one against the union, including the literal kind values.
  • Template literal values. pnr: `PNR${this.#nextPnr++}` produces a string that TypeScript accepts as a Pnr, because it can see the pattern matches.
  • readonly in practice. cancel changes booking.cancelled, which is allowed, but writing booking.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:

TypeScript
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.

TypeScriptRun it yourself
// ---------- 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);
Output
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 typed BookingRequest instead of unknown. The compiler rejects the lower-case class.
  • Call railway.cancel("4521890") without the PNR prefix. The template literal type Pnr rejects it.
  • Add "1A" to CLASSES. Errors appear in RATE_PER_KM, COACH_PREFIX and both train definitions, showing every place that needs the new class.
  • Remove the case "waiting": branch from statusText. The never check 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 const arrays, Omit, Record) so changes spread automatically.
  • Treat outside input as unknown and 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, readonly and exhaustive never checks 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
TypeScript
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 1

As 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?

2. What does Result<T> force callers to do?

3. Why does parseRequest take unknown rather than BookingRequest?

4. What happens if you add a new member to SeatStatus but forget to update statusText?

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