Transform server records into the view model a component renders

The problem

The API returns fields named for storage rather than for reading: a nullable room label, an ISO timestamp string, a status enum, and a guest name that is sometimes blank. The component should render one object per booking without deciding fallbacks, formatting times, or indexing a label map in JSX.

Short answer

Declare the server record type and a separate view model type, then write one total mapping function that turns a record plus an injected current time into the strings and flags the card consumes.

Once a payload is validated, the next boundary is the one between data shaped for storage and data shaped for reading. A mapping function keeps that decision in one place: the component receives strings and flags it can render without further logic.

Language: TypeScript
// src/booking-view-model.ts
export type ServerBooking = {
  readonly id: string;
  readonly guestName: string;
  readonly startsAt: string;
  readonly roomLabel: string | null;
  readonly status: "confirmed" | "held" | "cancelled";
};

export type BookingCard = {
  readonly key: string;
  readonly headline: string;
  readonly roomLine: string;
  readonly startsLine: string;
  readonly badge: string;
  readonly isUpcoming: boolean;
};

const statusLabels: Record<ServerBooking["status"], string> = {
  confirmed: "Confirmed",
  held: "On hold",
  cancelled: "Cancelled",
};

const timeFormat = new Intl.DateTimeFormat("en-GB", {
  hour: "2-digit",
  minute: "2-digit",
  timeZone: "UTC",
});

function parseInstant(rawStartsAt: string): number | null {
  const parsed = Date.parse(rawStartsAt);
  return Number.isNaN(parsed) ? null : parsed;
}

export function toBookingCard(record: ServerBooking, now: Date): BookingCard {
  const instant = parseInstant(record.startsAt);
  const trimmedName = record.guestName.trim();

  return {
    key: record.id,
    headline: trimmedName === "" ? "Guest name pending" : trimmedName,
    roomLine: record.roomLabel === null ? "Room not assigned yet" : `Room ${record.roomLabel}`,
    startsLine:
      instant === null ? "Start time unavailable" : `Starts ${timeFormat.format(new Date(instant))} UTC`,
    badge: statusLabels[record.status],
    isUpcoming: instant === null ? false : instant > now.getTime(),
  };
}

export function toBookingCards(
  records: readonly ServerBooking[],
  now: Date,
): readonly BookingCard[] {
  return records.map((record) => toBookingCard(record, now));
}

The component reads only view model properties, so a change to the server record shape is a mapper edit rather than a markup hunt.

Language: TSX
// src/booking-board.tsx
import { toBookingCards } from "./booking-view-model";
import type { BookingCard, ServerBooking } from "./booking-view-model";

type BookingBoardProps = {
  readonly bookings: readonly ServerBooking[];
  readonly now: Date;
};

function BookingCardView({ card }: { readonly card: BookingCard }) {
  return (
    <li className="booking-card">
      <h3>{card.headline}</h3>
      <p>
        {card.roomLine}
        <br />
        {card.startsLine}
      </p>
      <span className={card.isUpcoming ? "badge badge-live" : "badge"}>{card.badge}</span>
    </li>
  );
}

export function BookingBoard({ bookings, now }: BookingBoardProps) {
  const cards: readonly BookingCard[] = toBookingCards(bookings, now);

  if (cards.length === 0) {
    return <p className="empty-state">No bookings for this day.</p>;
  }

  return (
    <ul className="booking-board">
      {cards.map((card) => (
        <BookingCardView key={card.key} card={card} />
      ))}
    </ul>
  );
}

Explanation

There are two types here on purpose. ServerBooking mirrors what the API sends, including the nullable roomLabel and the string timestamp; BookingCard mirrors what the reader sees, which is why every property on it is already a complete sentence or a boolean. Deriving the second from the first in one function means the fallback text, the label wording, and the time format have exactly one home, and the component cannot disagree with itself about how a held booking should read. The mapper is total: for every ServerBooking it returns a BookingCard, so nothing downstream has to re-check for the blank name or the missing room.

Two type choices carry most of the weight. statusLabels is declared as Record<ServerBooking["status"], string>, so its keys must cover exactly the status union — the lookup statusLabels[record.status] is then a plain index with no undefined handling and no fallback branch, and adding a fourth status to the server union makes this object fail to compile until the label is written. isUpcoming is computed rather than stored, because it depends on the clock, which is why now is a parameter. Parsing is handled the same way: Date.parse returns NaN for an unrecognised string, and parseInstant converts that into null so the two consumers of the value — the displayed line and the comparison — can both fall back to something explicit instead of formatting NaN or silently classifying a broken record as past.

The boundary condition worth keeping in mind is that the record type is a promise made elsewhere. This mapper accepts values that already satisfy ServerBooking, so if the array arrives from fetch it must come through the response-narrowing step first; feeding it unchecked objects compiles, and the failure surfaces as "undefined" inside a template string rather than as a compiler error. A second boundary is time: startsLine is formatted in UTC with a fixed locale so the same record always prints the same text, which is the right trade-off for a schedule shared across regions but the wrong one for a local-only appointment book — change the timeZone option and the UTC suffix together, or the label will contradict the number.

Parameters

Named inputs for the code above
NameTypeRequiredDefaultDescription
recordsreadonly ServerBooking[]Yes—Already validated server records, in the order the list should render them.
nowDateYes—Injected clock used for the upcoming comparison, so the mapping stays deterministic in tests.

Expected output

One BookingCard per record in input order, with badge text from the status union, a room fallback when the label is null, and an unavailable line when the timestamp will not parse.

Usage notes

  • Pass the clock in rather than reading it inside the mapper, so a test can pin the boundary between upcoming and past bookings.
  • Keep the view model free of anything a component needs to decide later; if JSX still contains a conditional on a server field, that condition belongs in the mapping function.

Common mistakes

  • Rendering the raw nullable room label and letting JSX print it, which shows nothing at all instead of the assigned fallback sentence.

Caveats

  • The mapping is one-directional. Editing a card and sending it back needs a separate function from view model to server record, because formatted strings are lossy.
  • The formatter pins the UTC time zone so the output text does not depend on the reader's machine; localize it deliberately if your bookings are shown in local time.

Prerequisite

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References