Skip to Content
Course content

220: Designing a Good Public API Surface

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

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 (login and logout).
  • Make internal properties (like the DB connection) private.
  • Ensure the UserSession returned by the login method is Readonly so the consumer cannot manually change their user ID or permissions.
  • Export a factory function createAuthService instead 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}`;
  }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.