TypeScript
.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:
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
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:
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:
// 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 (??)
// 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:
// 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:
// 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:
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)
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?
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.
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?
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?
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?
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.