TypeScript Best Practices for Bangkok's Production Web Apps
23 April 2026 · by Yunmin Shin
Why TypeScript in Production?
TypeScript catches entire categories of bugs before they reach users. Type errors that would silently crash a runtime become compile-time failures a developer sees immediately. For production applications — especially ones handling payments, appointment data, or patient contact details — that safety net has real monetary value.
We feel this directly running our own sites. A clinic booking system built on plain JavaScript will happily let preferredDate be undefined all the way down to the SMS reminder job, and the failure only shows up when a real patient never gets their reminder and misses a treatment slot. TypeScript turns that into a red squiggle in the editor, days before it ever ships. Across 15+ live business sites — clinics, a restaurant discovery platform, real estate listings — that class of bug is the one we can least afford, because the person affected is a paying customer, not a test account.
Beyond safety, TypeScript improves the day-to-day experience on team projects. When a developer picks up a booking API they didn't write, typed function signatures and interfaces act as living documentation. They don't need to trace through three files to learn that createAppointment() expects a Thai phone number in a specific format and an optional LINE user ID — the type tells them.
What TypeScript Patterns Should You Use?
Strict mode is non-negotiable. Set "strict": true in tsconfig.json from day one. This enables strictNullChecks, noImplicitAny, and related rules. Retrofitting strict mode into a permissive codebase later is painful — every file becomes a small project of its own.
Prefer interfaces over type aliases for object shapes. Interfaces are extendable and produce clearer error messages. Use type for unions, intersections, and mapped types where interfaces don't apply — a treatment status is naturally a union, not an interface:
type AppointmentStatus = "pending" | "confirmed" | "completed" | "cancelled" | "no_show";
interface Appointment {
id: string;
clinicId: string;
treatment: string;
status: AppointmentStatus;
scheduledAt: Date;
patientPhone: string;
lineUserId?: string;
}
Use Zod for runtime validation at every trust boundary. TypeScript's types disappear at runtime — they're a compile-time tool only. Data arriving from a booking form, a LINE webhook, or a payment gateway callback is untyped from TypeScript's point of view no matter what your interfaces say. Zod lets you define a schema once, parse incoming data against it, and get a fully typed object back:
import { z } from "zod";
const bookingFormSchema = z.object({
name: z.string().min(1),
phone: z.string().regex(/^(\+66|0)[689]\d{8}$/, "Invalid Thai mobile number"),
treatment: z.enum(["botox", "filler", "laser", "consultation"]),
preferredDate: z.coerce.date(),
lineUserId: z.string().optional(),
});
type BookingForm = z.infer<typeof bookingFormSchema>;
export function handleBookingSubmit(payload: unknown): BookingForm {
return bookingFormSchema.parse(payload); // throws on invalid data
}
This is the correct pattern for any data crossing a trust boundary — a form submission on a clinic site, a webhook from LINE, or a callback from Omise or 2C2P after a payment attempt.
Avoid any entirely. If you find yourself reaching for any, use unknown and narrow it with type guards instead. any defeats the purpose of TypeScript — it's an escape hatch that quietly accumulates technical debt until nobody trusts the types anymore.
Use satisfies for config objects. The satisfies operator validates that an object matches a type while preserving each property's literal type — useful for a per-clinic pricing table where you still want autocomplete on individual treatment keys:
const treatmentPrices = {
botox: 8900,
filler: 12500,
laser: 3500,
} satisfies Record<string, number>;
How Do You Type the Places Where Data Actually Breaks?
Most production bugs we've dealt with across our own booking and ordering systems don't come from application logic — they come from the seams where external, unstructured data enters the app. Three seams show up on nearly every Bangkok business site we build:
- LINE webhooks. A LINE Official Account sends event payloads for messages, postbacks, and follows. The shape is documented but arrives as
unknownJSON over HTTP — validate it with Zod before touching it, the same way as the booking form above. - Payment gateway callbacks. PromptPay, Omise, and 2C2P all POST asynchronous confirmation payloads. Parse and validate these before updating an order's status — never trust
req.body.status === "successful"directly from the wire. - AI API responses. If a site uses OpenAI or Claude for a booking assistant or FAQ answering, the response is a string that may or may not be valid JSON even when you asked for structured output. Parse it with Zod and handle the parse failure explicitly rather than assuming the model always complies.
Treating all three the same way — untyped in, validated, typed out — removes an entire category of "it worked in testing" bugs that only show up once real Thai customers and real payment providers are involved.
How Should You Structure Types Across a Project?
Centralize shared domain types in a types/ directory at the project root — types/appointment.ts, types/order.ts, types/customer.ts — and keep component-specific types local to the component file. Avoid a single types.ts that becomes a dumping ground; splitting by domain keeps a clinic booking type change from touching unrelated e-commerce code in the same repo.
If your database is Supabase, generate types directly from the schema rather than writing them by hand:
npx supabase gen types typescript --project-id your-project-id > types/database.ts
This matters more than it sounds like on real client projects: clinic staff sometimes add a column through the Supabase dashboard without telling the developer. Regenerating types after every schema change is what turns that into a caught compile error instead of a silent undefined in production.
For API response types more generally, generate them from an OpenAPI spec or your Zod schemas rather than hand-writing duplicates. Tools like zod-to-openapi keep the runtime validator and the compile-time type as a single source of truth instead of two things that quietly drift apart.
What About TypeScript 5.x Features?
TypeScript 5.x brought stable decorators, improved inference in conditional types, and const type parameters — the most practically useful addition for most teams, since it preserves literal types in generic functions without the as const workaround:
function createStatusEvent<const T extends string>(status: T) {
return { status, timestamp: new Date() };
}
At Bluewich, every project — whether it's a client site or one of the businesses we operate ourselves — ships with strict TypeScript, Zod validation at every API boundary, and domain-separated type files generated from the actual database schema. That structure scales from a three-page landing site to a multi-clinic booking platform without a rewrite in between.
Ready to Build Something Fast?
Get a free quote. We reply within 24 hours.
Ready to build something fast and scalable?
Get a free project quote. We reply within 24 hours.
Get a Free Quote →