TypeScript
Completed
-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
220: Designing a Good Public API Surface
Think about the last time you went to a professional restaurant. When you sit down, the waiter hands you a menu. That menu is a carefully curated list of options. It tells you exactly what you can order and what you'll get back. You don't see the walk-in freezer, you don't see the dishwasher's station, and you certainly aren't allowed to walk into the kitchen and start flipping burgers yourself. If you could, you'd probably break something, or the chef would scream at you. The menu is the "Public API," and the kitchen is the "Implementation."
In TypeScript, we often make the mistake of letting our users walk right into the kitchen. We export every class, every helper function, and every raw database model we have. It feels efficient at first, but you're essentially telling every developer using your code, "Here is everything I'm doing; feel free to rely on any part of it." The moment you want to change how you store data or swap a library, you'll realize you've accidentally promised to support those internals forever.
The Menu vs. The Kitchen
To build a professional API surface, you need to draw a hard line between what is internal and what is public. In a TypeScript project, this usually means being very stingy with the export keyword. If a function doesn't need to be called by the consumer, don't export it. Period.
Let's look at a bad example. Imagine we're building a PaymentProcessor. A "leaky" API looks like this:
// ❌ The Leaky API
export class PaymentProcessor {
// This is an internal detail of the Stripe SDK, but we're exposing it!
public stripeClient: any;
public async processPayment(amount: number, currency: string) {
// logic here...
}
// Why is this public? The user shouldn't be manually clearing the cache.
public clearInternalCache() {
// logic here...
}
}
The user of this class now sees stripeClient and clearInternalCache in their autocomplete. They might start using them. Once they do, you can't replace Stripe with Braintree or change your caching logic without breaking their entire application. You've given them a map of your kitchen instead of a menu.
Stop Leaking Your Internals
The goal is to expose intent, not implementation. Instead of exporting your classes and raw types, I highly recommend exporting a lean interface or a set of focused functions. This decouples the "what" from the "how."
Here is how I would refactor that PaymentProcessor to have a clean public surface:
// ✅ The Curated API
export interface PaymentResult {
success: boolean;
transactionId: string;
error?: string;
}
export interface IPaymentProcessor {
processPayment(amount: number, currency: string): Promise<PaymentResult>;
}
class PaymentProcessor implements IPaymentProcessor {
private stripeClient: any; // Hidden from the user
async processPayment(amount: number, currency: string): Promise<PaymentResult> {
// All the messy kitchen logic stays here
return { success: true, transactionId: '123' };
}
private clearInternalCache() {
// Only the class itself can call this
}
}
// We export a factory function, not the class itself
export const createPaymentProcessor = (): IPaymentProcessor => {
return new PaymentProcessor();
};
By exporting IPaymentProcessor and a factory function, the user never even knows the PaymentProcessor class exists. They just know they have an object that can processPayment. I love this approach because it gives me total freedom to rewrite the entire class from scratch as long as I keep the interface the same.
Protecting Your Future Self with Readonly
One last thing: be careful with the objects you return. If you return a public object that is mutable, the user can change it, which might break your internal state in ways that are nearly impossible to debug. I always use Readonly or readonly modifiers for API return types.
If your API returns a configuration object, don't just return UserConfig. Return Readonly<UserConfig>. It's a small signal to the developer that says, "You can look at this, but don't touch it." It prevents a whole category of bugs where a consumer accidentally mutates a shared state and wonders why the application started behaving randomly.
📋 Practical Task
Refactoring the Leaky UserAuthenticationService
You have been handed a UserAuthenticationService that is far too "open." The current implementation exposes the internal database connection, a raw session object that can be mutated by the caller, and an internal helper method used for password hashing.
Your Task: Refactor the following code to create a professional public API surface.
- Create a public interface that defines only the necessary methods (
loginandlogout). - Make internal properties (like the DB connection)
private. - Ensure the
UserSessionreturned by the login method isReadonlyso the consumer cannot manually change their user ID or permissions. - Export a factory function
createAuthServiceinstead of the class itself.
// Current Messy Code
export class UserAuthenticationService {
public dbConnection = "Connected to Postgres"; // Internal detail!
public async login(user: string, pass: string) {
console.log("Authenticating...");
return { userId: 'u123', role: 'admin', token: 'abc-123' };
}
public async logout() {
console.log("Logging out...");
}
public hashPassword(pass: string) { // Internal helper!
return `hashed_${pass}`;
}
}
There are no comments for now.