Chapter 2: TypeScript - The Architectural Contract for SaaS Projects (vs. Mypy)
In Chapter 1, we explored Node.js's asynchronous philosophy. Now, let's talk about JavaScript's safety belt: TypeScript (TS).
Chapter 2: TypeScript - The Architectural Contract for SaaS Projects (vs. Mypy)
In Chapter 1, we explored Node.js's asynchronous philosophy. Now, let's talk about JavaScript ecosystem's safety belt: TypeScript (TS).
As a Python developer, you might love Python's dynamic nature but also miss type hints in large projects, relying on Mypy for static checking.
JavaScript itself is 100% dynamically typed. For an enterprise-level SaaS project requiring long-term maintenance and multi-person collaboration, this is a disaster. TypeScript isn't an "option" - it's the project's "Architectural Contract". It defines the shape specification of data flowing between the database (Drizzle), backend (Server Actions), and frontend (React).
2.1. Why TS is Essential for Large SaaS Projects
You might think: "Python has Mypy too, what's different?"
This is a fundamental difference:
- Mypy is a "checker". You write Python code first, then run
mypyto check for errors. If Mypy reports errors, your Python code can still run (though it might crash at runtime). - TypeScript is a "compiler". You write
.tsfiles, and the TypeScript compiler (TSC) transforms them into.jsfiles that browsers or Node.js can understand. If types don't match, the code simply won't compile.
For our SaaS project, TS provides four core values:
- Unparalleled "IntelliSense": When you type
user.in VS Code, the IDE immediately tells you whether thisuserobject hasid,email,credits, orsubscriptionId. You no longer need to check the Drizzle schema definition in thesrc/db/directory. - Safe refactoring: Suppose you need to rename the
user.creditsfield touser.creditBalance. In a pure JS project, this is a nightmare - you need to search globally and pray you didn't miss anything. In a TS project, you only rename the field in thesrc/db/schema, and the compiler immediately marks every error throughout the project (in Actions, APIs, frontend pages). - Clear "data contracts": Our
src/actions/directory has a functionupdateUserSettings. TS forces you to clearly define what parameters it accepts (userId: string,settings: UserSettings) and what it returns (Promise<void>). - Eliminates entire categories of bugs: You'll never encounter
TypeError: undefined is not a function- the most common runtime error in JS - because the compiler catches them at build time.
All of TS's magic is controlled by the tsconfig.json file in the root directory. For SaaS projects, "strict": true is our mandatory safety standard. It enables all strictest type checks (like strictNullChecks), ensuring you don't accidentally handle a null or undefined value - a key point Python developers (accustomed to None) must adapt to.
2.2. Core Types: interface vs type
In Python, you might use TypedDict or dataclass to define data structures. In TS, the two most common approaches are interface and type.
This often confuses beginners, so let's clarify:
interface (Interface)
- Purpose: Focuses on defining the shape of objects.
- Feature: Supports "declaration merging".
// Specifically for describing what an object should look like
interface User {
id: string;
email: string;
}
// In another file, you can "extend" this interface
// This is useful for patching third-party libraries (like Better Auth)
interface User {
credits: number;
}
// Now User interface has id, email, and creditstype (Type Alias)
- Purpose: More flexible, can create an "alias" for any type.
- Feature: Cannot declaration merge, but more comprehensive functionality.
// 1. Describe objects (same as interface)
type User = {
id: string;
email: string;
};
// 2. Describe union types
// This is something interface cannot do
type PaymentStatus = "pending" | "paid" | "failed";
// 3. Describe a function signature
type EmailSender = (to: string, body: string) => Promise<boolean>;
// 4. Combine types (Intersection & Union)
type UserWithPlan = User & { plan: PaymentPlan }; // CombineWhy not use enum? Python developers might be used to Enum. TypeScript also has enum, but modern TS practices (especially in Next.js projects) prefer string literal unions (as shown in PaymentStatus).
- Reason: Union types are simpler, have zero runtime overhead (
enumcompiles to a JS object), and are more intuitive when debugging (you see"pending"instead of the number0corresponding toPaymentStatus.PENDING).
How to choose in our SaaS project?
- Golden Rule: Prefer
type. - Reason:
typeis more consistent and flexible. It can do everythinginterfacecan do (describe objects) and more (like union types). This reduces your cognitive load of "which one should I use?". - Exception: Only use
interfacewhen you need to leverage "declaration merging" to extend an existing third-party type (like theauth.tsexample in section 2.5).
2.3. Zod: Runtime Data Firewall (vs. Pydantic)
This is the most critical concept of this chapter, and the most confusing part for developers transitioning from Python's Pydantic.
TypeScript's limitation: only exists at "compile time".
When your Next.js app is compiled to JavaScript and runs on the server, all TS type information is erased.
If at this point, an external request (e.g., user-submitted form, or Stripe Webhook received by src/payment/) sends JSON data to your src/actions/, what happens?
// A Server Action
export async function updateUser(formData: FormData) {
'use server';
// You "tricked" the TS compiler, telling it this is User type
// But formData might be malicious or incomplete
const data = Object.fromEntries(formData) as User;
// TS won't error here, but data.email might be undefined
// This will cause a database crash at runtime
await db.update(users).set({ email: data.email.toLowerCase() });
}What you're familiar with: Pydantic
In Python/FastAPI, you'd use Pydantic. Pydantic checks the incoming dict at runtime, and if the email field is missing or has the wrong type, it immediately throws a ValidationError.
What you need to master: Zod Zod is the Pydantic of the TypeScript world. It's a "firewall" for validating data shape at runtime.
Its most powerful feature is "Single Source of Truth": you define a Schema with Zod, then Zod automatically infers TypeScript types for you.
import { z } from 'zod';
// 1. Define "runtime" Schema
export const UpdateUserSchema = z.object({
// Zod provides rich validators
email: z.string().email("Invalid email address"),
name: z.string().min(2, "Name must be at least 2 characters"),
});
// 2. Infer "compile-time" TS type from Schema
export type UpdateUserPayload = z.infer<typeof UpdateUserSchema>;
/*
`UpdateUserPayload` type now equals:
type UpdateUserPayload = {
email: string;
name: string;
}
*/Applying Zod in our SaaS project:
Now, let's refactor that unsafe Server Action:
import { UpdateUserSchema } from '@/lib/schemas'; // Assume we put Schema here
export async function updateUser(formData: FormData) {
'use server';
const rawData = Object.fromEntries(formData);
// 3. Parse and validate at "runtime"
const validationResult = UpdateUserSchema.safeParse(rawData);
if (!validationResult.success) {
// Validation failed, safely return error
return { error: validationResult.error.flatten().fieldErrors };
}
// 4. Only data that "passed the firewall" enters your business logic
// `data` is now 100% type-safe
const data = validationResult.data;
await db.update(users).set({
email: data.email.toLowerCase(),
name: data.name,
});
}Summary: TypeScript guarantees the contract between your internal code. Zod guarantees the contract between your application and the external world (user input, APIs, Webhooks).
This perfectly echoes the algorithmic thinking in section 1.4: Zod's process of parsing your schema is essentially traversing a data "tree", recursively checking every node and leaf (email, name) to ensure they comply with the "contract" you defined in the schema.
2.4. TS Advanced: Utility Types & Generics
If interface and type are for "making parts", then Utility Types and Generics are for "assembling reusable advanced modules". SaaS projects are full of needs to "fine-tune" existing types.
2.4.1 Utility Types: CRUD for Types
-
Partial<T>(Update):- SaaS scenario: Imagine a "user settings" update form. The user might only modify
name, notemail. TheupdateUserfunction insrc/actions/shouldn't require a completeUserobject. - Contract: This Action's
payloadtype should bePartial<User>, indicating that all fields onUserbecome optional.
- SaaS scenario: Imagine a "user settings" update form. The user might only modify
-
Pick<T, K>(Read) /Omit<T, K>(Delete):- SaaS scenario: Your Drizzle
Usertype (fromsrc/types/db.ts) includesid,email,hashedPassword,createdAt. - Contract (Pick): When creating a "public user profile" type, you might only need
idandname.type PublicUser = Pick<User, 'id' | 'name'>; - Contract (Omit): When creating a
createUserfunction, you need all fields exceptidandcreatedAt(which are generated by the database).type CreateUserPayload = Omit<User, 'id' | 'createdAt' | 'hashedPassword'>;
- SaaS scenario: Your Drizzle
2.4.2 Generics (<T>): Creating Reusable "Type Functions"
- Python analogy: Very similar to
TypeVarandGenericin Python'stypingmodule. - SaaS scenario: In
src/actions/, we want all Server Actions to return a standardized response object for unified frontend handling. - Contract:
// Define a "generic" response type
// T can be User, Subscription, or null
export type ActionResponse<T> = {
success: boolean;
data: T | null;
error: string | null;
}
// Use in Action
import { User } from '@/types';
async function getUser(id: string): Promise<ActionResponse<User>> {
'use server';
try {
const user = await db.query.users.findFirst({ where: eq(users.id, id) });
if (!user) {
return { success: false, data: null, error: "User not found" };
}
// Omit sensitive data, echoing 2.4.1
const publicUser = { id: user.id, email: user.email }; // Assume
// Note the data type is User (or subset thereof)
return { success: true, data: publicUser, error: null };
} catch (e) {
return { success: false, data: null, error: "Database error" };
}
}2.5. [Code Analysis]: Analyzing src/types/ Directory in Real Project
In our SaaS project, the src/types/ directory isn't a "dumping ground" - it's the central hub of our "architectural contracts". While many types live alongside their logic (like Zod Schemas), src/types/ focuses on storing widely shared core data models.
Opening this directory, we might see:
-
src/types/db.tsThis is Drizzle ORM's core. Drizzle has a powerful feature: it can automatically infer TS types from your database Schema (defined in src/db/schema.ts). This file contains code like:
// What a User looks like when "select" from database export type User = typeof usersTable.$inferSelect; // What fields are needed when "insert" a User export type NewUser = typeof usersTable.$inferInsert; export type Subscription = typeof subscriptionsTable.$inferSelect;Value: Your database and TS types are always in sync.
-
src/types/index.tsA main export file that re-exports all other types for convenient unified importing in the project.
export * from './db'; export * from './auth'; export * from './stripe';Usage:
import { User, Subscription } from '@/types'; -
src/types/auth.tsWe use Better Auth. Better Auth's auth() function returns a session object. But we might want to add custom data to this session (like user's credits or plan). This file uses interface's "declaration merging" feature (the exception mentioned in section 2.2) to extend Better Auth's default types:
import 'better-auth'; // Import original module declare module 'better-auth' { // Extend default Session and User types interface Session { user: User & { id: string; credits: number; plan: 'free' | 'pro'; } } }Value: Now, when you call auth() anywhere in the project, TS knows that session.user has credits and plan.
-
src/types/stripe.tsSpecifically for types in the src/payment/ directory. Stripe's official types are massive and complex. We define our own simplified "contracts" that our application cares about.
export type AppSubscriptionStatus = 'active' | 'canceled' | 'past_due' | 'unpaid'; export type CreditTransaction = { id: string; amount: number; type: 'purchase' | 'usage'; // Using union type again createdAt: Date; }
Categories
interface (Interface)type (Type Alias)2.3. Zod: Runtime Data Firewall (vs. Pydantic)2.4. TS Advanced: Utility Types & Generics2.4.1 Utility Types: CRUD for Types2.4.2 Generics (<T>): Creating Reusable "Type Functions"2.5. [Code Analysis]: Analyzing src/types/ Directory in Real ProjectMore Posts
Chapter 15: Code Quality (Biome) and Content (Fumadocs)
In Chapter 14, we established a solid CI/CD pipeline that acts as the 'gatekeeper' for our SAAS application. One of the core responsibilities of this gatekeeper is running pnpm lint and pnpm typecheck. But what's actually working behind these commands?
Chapter 9: [Deep Dive] Multi-Tenant Architecture Design
Welcome to Chapter Nine. So far, we've built a single-user system: users log in and create their own projects. But almost all successful SAAS (Software as a Service) applications (like Slack, Notion, Figma) are not single-user systems, they are multi-tenant systems.
Chapter 10: Database Migrations and Operations
In Chapters 7 and 9, we defined our database table structure in the src/db/schema/ directory. But we left a critical question: when you modify the schema (like adding a bio field to usersTable), how do you safely apply this change to a production database that's already running live?