Skip to Content
Course content

119: Enforcing Type Boundaries Between Modules

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Think of a professional kitchen. When you're a customer, you interact with a menu. The menu is the "public API." You order a "Medium-Rare Ribeye," and the kitchen delivers it. You don't know—and shouldn't care—which brand of grill they used, how the walk-in freezer is organized, or the specific internal shorthand the chefs use to communicate. If the chef decides to switch from a gas grill to charcoal, your experience of ordering a ribeye remains exactly the same. The boundary between the dining room and the kitchen is strictly enforced.

In TypeScript, we often accidentally let the customers walk right into the kitchen. We export internal interfaces or use generic types (like string or number) for everything. This creates a "leaky boundary." If you use a string for a UserId, a ProductId, and an OrderNumber, TypeScript will let you pass a ProductId into a function that expects a UserId. The compiler is happy, but your database is about to scream.

The Danger of the Generic String

I've seen this happen in huge codebases: a developer creates a PaymentService and a UserService. Both use string for their identifiers. Eventually, someone writes a cleanup script and accidentally passes a userId into paymentService.refundTransaction(transactionId). Because both are just strings, the code compiles perfectly, and you end up trying to refund a user instead of a transaction.

// The "Leaky" Way
export type UserId = string;
export type TransactionId = string;

export function refundTransaction(tid: TransactionId) { 
    /* ... */ 
}

const myUserId: UserId = "user_123";
// TypeScript allows this! It's just a string.
refundTransaction(myUserId); 

Branding Your Types for Strictness

To stop this, we need to "brand" our types. Since TypeScript uses structural typing (if it looks like a duck, it's a duck), we have to add a unique "mark" to the type that doesn't actually exist at runtime but tells the compiler: "This isn't just any string; it's specifically a TransactionId."

Here is how I typically handle this. We use an intersection type with a unique property that can never actually be assigned a value.

// The "Enforced Boundary" Way
export type Brand<K, T> = T & { __brand: K };

export type UserId = Brand<"UserId", string>;
export type TransactionId = Brand<"TransactionId", string>;

// We need a "Type Guard" or a casting function to create these
export function createTransactionId(id: string): TransactionId {
    return id as TransactionId;
}

export function refundTransaction(tid: TransactionId) {
    console.log(`Refunding ${tid}`);
}

const myUserId = "user_123" as UserId;
const myTxId = createTransactionId("tx_999");

refundTransaction(myTxId); // Works great.
// refundTransaction(myUserId); // ERROR: Type 'UserId' is not assignable to 'TransactionId'.

Protecting the Implementation Leak

Beyond just IDs, you should be careful about exporting your internal data models. I always recommend creating a "Public" version of a type and an "Internal" version. The internal version might have sensitive fields or complex logic, while the public version is a stripped-down interface.

If you export your internal database entity directly, every single component that uses that entity is now coupled to your database schema. If you rename a column in your DB, you might have to update 50 different files in your frontend. Instead, map your internal type to a public-facing one at the boundary of your module. It's a bit more boilerplate upfront, but it saves you from a refactoring nightmare six months down the line.




📋 Practical Task

Exercise: Implementing a Secure Session Token Guard

You are building an authentication module. Currently, the system uses plain strings for SessionTokens and ApiKeys, which is causing bugs where API keys are being passed into session-validation functions.

Your Task:

  • Implement a Brand type utility that can be used to differentiate between different string-based identifiers.
  • Create two branded types: SessionToken and ApiKey.
  • Create a factory function createSessionToken(token: string): SessionToken that casts a raw string into a branded SessionToken.
  • Create a function validateSession(token: SessionToken): boolean that only accepts the branded SessionToken.
  • Demonstrate that passing a raw string or an ApiKey into validateSession results in a TypeScript compilation error.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.