37.1 Designing a Public API with TypeScript
Alright, let’s talk about designing a public API. This is where you move from writing code for yourself to writing code for everyone else. It’s the contract you sign with your users, promising not to be a jerk and break their entire codebase with a surprise update at 2 AM. TypeScript is our not-so-secret weapon here, letting us write that contract in iron-clad, type-safe ink. But with great power comes great responsibility to not design something truly idiotic.
The first rule is this: your public API is a tiny, well-fortified castle. Your private implementation is the sprawling, messy, and occasionally on-fire village outside the walls. Your users get to live in the castle. They don’t need to see the garbage pits in the village. Use private and protected keywords liberally. Mark internal methods with @internal JSDoc or // @ts-ignore if you have to (we’ll get to that). The goal is to make the right way to use your library the only obvious way.
The Power of interface and type
You might think, “I’ll just export my main class, that’s the API.” Slow down, cowboy. Exporting a class means you’re exporting its entire shape—every public method is now a permanent part of your contract. Prefer defining the contract of what your library does separately from its implementation. This is where interface shines.
// The Contract. This is what you promise to your users.
export interface CacheStore {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
}
// The Implementation. You can change this as long as you fulfill the contract.
class RedisCacheStore implements CacheStore {
// ... private connection details, retry logic, etc.
async get(key: string): Promise<string | null> {
// Your messy, complicated implementation here
}
async set(key: string, value: string): Promise<void> {
// More messy stuff the user doesn't need to see
}
}
// Your factory function returns the contract, not the implementation.
export function createRedisCache(connectionString: string): CacheStore {
return new RedisCacheStore(connectionString);
}
See the magic? The user only knows about CacheStore. You can completely rewrite RedisCacheStore, rename it, or replace it with a different client library, and no user code breaks. You’ve hidden the implementation details, which is just good manners.
Function Overloading is Your Frenemy
TypeScript’s function overloading is brilliant for designing elegant APIs that handle multiple input types. It’s also a great way to paint yourself into a terrifyingly complex type corner if you’re not careful.
The classic use case is a get function that returns different types based on the input.
// Overload signatures: what the caller sees
function getConfig(key: 'apiUrl'): string;
function getConfig(key: 'timeout'): number;
function getConfig(key: 'retryPolicy'): { attempts: number; delay: number };
// Implementation signature: what you actually write. This is internal.
function getConfig(key: string): string | number | { attempts: number; delay: number } {
// ... implementation that actually figures out what to return
const config = internalConfigStore[key];
return config;
}
// Usage: Beautiful, precise, and safe.
const url = getConfig('apiUrl'); // Type: string
const timeout = getConfig('timeout'); // Type: number
const policy = getConfig('retryPolicy'); // Type: { attempts: number; delay: number }
The implementation signature is a bit ugly, but that’s the point—it’s your problem, hidden behind the castle walls. The user gets a beautifully typed experience. Just remember: the implementation has to be compatible with all the overloads. Don’t promise a string and return a number; that’s how trust issues start.
Generics: The Ultimate Flexibility Tool
Generics are how you avoid being that library that just slams any everywhere. They let your users bring their own types, and you just provide the structure.
Imagine a cache function. Without generics, it’s practically useless.
// Bad. So bad. What did I even get back? Who knows?
function cache(key: string, value: any): any { ... }
// Good. We're respecting the user's types.
function cache<T>(key: string, value: T): T {
// ... store it
return value;
}
// Excellent. Now the type is preserved through the cache.
const myData: MyComplexType = cache('my-key', myComplexData);
Always provide a default for generics if it makes sense, but allow the user to specify their own. It’s the polite thing to do.
function createArray<T = string>(items?: T[]): T[] {
return items ?? [];
}
const stringArray = createArray(); // string[]
const numberArray = createArray([1, 2, 3]); // number[]
The Pitfalls: any and Branded Types
The biggest pitfall is laziness. Leaking any into your public API is like showing up to a black-tie event in pajamas. It undermines the entire point of using TypeScript. Use unknown before you ever consider any in a public-facing type. If a user passes you something, you don’t know what it is. unknown forces you to check, which is correct.
Another common issue is distinguishing between two types that are structurally the same but semantically different. A UserID and a PaymentID might both be strings, but you should never mix them up. This is where branded types (or “opaque types”) come in.
// Brand your types to prevent accidental mixing.
type UserID = string & { readonly __brand: unique symbol };
type PaymentID = string & { readonly __brand: unique symbol };
// Helper function to create a branded type (asserting it's safe)
function createUserID(id: string): UserID {
return id as UserID;
}
const uid = createUserID('user-123');
const pid = createPaymentID('payment-456');
// This will be a compile-time error, saving you from a nasty bug.
const isSame = uid === pid; // Error: Type 'UserID' is not comparable to type 'PaymentID'.
It’s a bit of a hack, but it’s a brilliant one that moves a whole class of potential runtime errors to compile time. Your users will thank you when they don’t accidentally charge the wrong person.