โ† Next.js 11 / 18 Next: Tailwind โ†’

TypeScript

Module 4 ยท Frontend ยท โฑ ~20 min
Why this matters: Every .ts and .tsx file in the frontend is TypeScript. TypeScript adds types to JavaScript โ€” it catches mistakes before you run the code. The BookingAdmin interface in lib/types.ts mirrors the Pydantic BookingAdminOut schema in Python.

Why TypeScript?

Plain JavaScript is dynamic โ€” you can pass any value anywhere, and errors only appear at runtime. TypeScript adds a type system that catches errors before you run the code:

javascript โ€” JavaScript catches errors at runtime (bad)
function fmtTime(hms) {
  return hms.slice(0, 5)  // "08:00:00" โ†’ "08:00"
}
fmtTime(null)  // TypeError: Cannot read properties of null
// You only discover this when the user hits that code path
typescript โ€” TypeScript catches errors before running
function fmtTime(hms: string): string {
  return hms.slice(0, 5)
}
fmtTime(null)  // โœ˜ TypeScript ERROR: Argument of type 'null' is not assignable to parameter of type 'string'
// The editor shows the error instantly, before you even save the file

Interfaces: Describing Data Shapes

Interfaces describe the shape of an object. From lib/types.ts:

typescript โ€” frontend/lib/types.ts
export interface BookingAdmin {
  id: string                        // required string
  resource_id: string
  service_id: string | null         // can be string OR null
  customer_name: string
  customer_phone: string
  customer_line_user_id: string | null  // null if LINE not connected
  booking_date: string              // "YYYY-MM-DD"
  start_time: string                // "HH:MM:SS"
  end_time: string
  duration_min: number              // integer minutes
  total_price: string               // "150.00" โ€” string to avoid float issues
  status: string
  expires_at: string | null
  confirmed_at: string | null
  created_at: string
}

export interface Resource {
  id: string
  name_th: string
  name_en: string
  resource_type: 'asset' | 'staff'  // literal union โ€” ONLY these two values
  price_per_hour: string | null
  sort_order: number
  services: Service[]               // array of Service objects
}

Union Types: string | null

string | null means the value can be either a string OR null. This forces you to handle both cases:

typescript โ€” handling union types
// b.customer_line_user_id is string | null
if (b.customer_line_user_id) {
  // TypeScript knows it's a string here
  console.log(b.customer_line_user_id.length)  // โœ… works
}
// Outside the if block, TypeScript still treats it as string | null

Optional Chaining (?.) and Nullish Coalescing (??)

typescript โ€” defensive access patterns
// Optional chaining: returns undefined instead of crashing if null/undefined
const shopId = booking?.shop_id   // undefined if booking is null
const name = user?.profile?.displayName  // chains through multiple levels

// Nullish coalescing: use fallback if null or undefined (but NOT for 0 or "")
const domain = process.env.NEXT_PUBLIC_BASE_DOMAIN ?? 'localhost'
// If env var is not set (undefined), use 'localhost'

// Combined: get name or fallback
const displayName = booking?.customer_name ?? 'Unknown Customer'

Literal Union Types

You can use specific string values as types:

typescript โ€” literal types from the project
// resource_type can ONLY be 'asset' or 'staff'
resource_type: 'asset' | 'staff'

// BookingOut.status can ONLY be these values
status: 'pending_payment' | 'confirmed' | 'cancelled'

// BookingFlow step type
type Step = 'service' | 'duration' | 'date' | 'time' | 'details' | 'review' | 'payment' | 'success'

// TypeScript will catch this mistake:
const myStep: Step = 'payment_done'  // โœ˜ ERROR: 'payment_done' is not a Step

Generics: Promise<T>

Generics let a type work with different data types while still being type-safe:

typescript โ€” generics in the project
// proxyGet is generic โ€” T can be any type
async function proxyGet<T>(path: string): Promise<T> {
  const res = await fetch(`/api/admin/proxy${path}`, { cache: 'no-store' })
  if (!res.ok) throw new Error(`${res.status}`)
  return res.json()
}

// Usage: TypeScript infers the correct type
const bookings = await proxyGet<BookingAdmin[]>('/bookings')
// bookings is now typed as BookingAdmin[], not 'any'

const resources = await proxyGet<ResourceAdmin[]>('/resources')
// resources is typed as ResourceAdmin[]

// useState also uses generics:
const [bookings, setBookings] = useState<BookingAdmin[]>([])
// TypeScript knows setBookings must receive BookingAdmin[], not any array

Pydantic โ†” TypeScript: The Mirror Pattern

The Python Pydantic schemas and TypeScript interfaces must match. Here's the same booking create type in both languages:

python โ€” backend/app/schemas/public.py
class BookingCreate(BaseModel):
    resource_id: UUID                              # UUID
    service_id: UUID | None = None                 # optional UUID
    booking_date: date                             # date
    start_time: time                               # time
    duration_min: int = Field(default=60, ge=15, le=480)
    customer_name: str = Field(min_length=1, max_length=200)
    customer_phone: str = Field(min_length=1, max_length=20)
typescript โ€” frontend/lib/types.ts
export interface BookingCreate {
  resource_id: string       // UUID sent as string in JSON
  service_id: string | null // null when not a staff booking
  booking_date: string      // "YYYY-MM-DD" format
  start_time: string        // "HH:MM:SS" format
  duration_min: number      // integer
  customer_name: string
  customer_phone: string
}

JSON doesn't have native UUID or date types โ€” everything is strings and numbers. Python's Pydantic converts strings from JSON into the right Python types automatically. TypeScript keeps them as strings because that's what comes over the wire.

๐Ÿง  Self-Check Quiz

1. What does service_id: string | null mean in a TypeScript interface?

The service_id field can hold either a string (a UUID for staff bookings that require a specific service) or null (for asset bookings like courts, which don't have a specific service). TypeScript will raise an error if you try to call string methods on it without first checking that it's not null.

2. What does optional chaining (?.) do? Give an example of when you'd use it.

Optional chaining returns undefined instead of throwing a TypeError if the left side is null or undefined. Example: booking?.customer_line_user_id โ€” if booking is null, instead of "Cannot read properties of null", you get undefined. Used when a value might be null but you still want to access a property of it safely.

3. Why is total_price typed as string in the TypeScript interface even though it represents a price?

JSON numbers are floating-point and can introduce rounding errors (0.1 + 0.2 = 0.30000000000000004). The backend stores prices as NUMERIC(10,2) in Python's Decimal type and serializes them as strings in JSON ("150.00"). The frontend receives the string and uses Number(b.total_price).toLocaleString() only for display โ€” never for arithmetic.

4. In async function proxyGet<T>(path: string): Promise<T>, what does the T represent?

T is a type parameter โ€” a placeholder for any type specified at the call site. When called as proxyGet<BookingAdmin[]>('/bookings'), TypeScript replaces T with BookingAdmin[], so the return type becomes Promise<BookingAdmin[]>. This makes one function reusable for many different API endpoints while keeping full type safety.

5. Why does the TypeScript BookingCreate interface use string for resource_id, while the Python Pydantic model uses UUID?

JSON doesn't have a native UUID type โ€” over the wire, UUIDs are just strings like "705f95b6-010f-4038-adf1-427f1e0f5527". TypeScript uses string because that's what the JSON parsing gives you. Python's Pydantic accepts that string and automatically converts it to a Python UUID object using its type annotation โ€” both sides agree on the format but use their language's native types.