Design Public TypeScript APIs
Learn how to design public TypeScript APIs that are easy to use, hard to misuse, and stable enough for external consumers to depend on.
A public API is a promise. When other developers import your package, they trust that the types you export are accurate, the function signatures are stable, and a minor version bump will not break their code.
Good TypeScript API design means using the type system to make that promise enforceable by the compiler.
The goal is simple: make correct usage the only usage that compiles. If a consumer can pass the wrong kind of ID to your function, your API is too loose.
If they can forget to handle an error case, your return type is missing a branch. Every type you export either strengthens or weakens the promise.
Export Narrow Types
The most impactful decision in API design is how narrow to make your types. Start with the narrowest type that satisfies the use case, then widen only when consumers prove they need more flexibility.
A function that accepts a status string should not accept any string. It should accept only the specific status values that the function handles. The compiler then rejects invalid values at the call site before the code ever runs:
// Too loose -- accepts any string
declare function setStatus(status: string): void;
// Just right -- only valid values compile
type Status = "active" | "inactive" | "suspended";
declare function setStatus(status: Status): void;The narrow version catches typos at compile time. Passing "activ" instead of "active" produces a compiler error. The wide version silently accepts the typo and fails at runtime, or worse, silently does the wrong thing.
This principle applies to every parameter. If a quantity must be positive, do not accept number.
Create a branded PositiveNumber type that can only be constructed through a validation function. The type tells the story that the parameter name cannot.
Brand Your Primitives
A string is a string, but a user ID is not an order ID. When your API accepts or returns identifiers, brand them so the compiler prevents accidental swaps. A branded type wraps a primitive with a compile-time tag that distinguishes it from every other primitive:
type Brand<T, B> = T & { __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;Now functions that expect different IDs cannot be confused. The compiler rejects passing a UserId where an OrderId is expected, even though both are strings at runtime. This single pattern eliminates an entire class of bugs in public APIs.
The brand should be created inside a factory function that validates the input. External consumers should never cast to your branded types directly. The factory is the gatekeeper that guarantees every branded value is valid before it enters your library.
Return Results, Not Throws
A public API function that throws on failure hides its failure modes from the type signature. The consumer cannot see from the return type that the function might fail. A Result type makes every outcome explicit in the return signature and forces the consumer to handle both branches:
type Result<T, E> =
| { success: true; value: T }
| { success: false; error: E };
type PaymentReceipt = { id: string; amount: number };
type PaymentError = "InsufficientFunds" | "NetworkError" | "InvalidCard";
declare function charge(
amount: number,
token: string
): Promise<Result<PaymentReceipt, PaymentError>>;The consumer must check the success field before accessing the value. TypeScript narrows the type inside each branch, so accessing value in the error branch produces a compiler error. The return type documents every possible failure mode without requiring the consumer to read documentation.
Reserve throws for truly unexpected errors like programming mistakes and assertion failures. Expected failures like validation errors, network timeouts, and missing resources belong in the return type.
Design for the Caller, Not the Implementation
It is tempting to export types that mirror your internal data structures. A type that exactly matches your database row is easy to write but hard to consume.
The consumer does not care about your storage format. They care about what they need to pass and what they get back.
Design your function parameters as flat, focused objects that ask for exactly what the function needs and nothing more. Design your return types as complete, well-structured objects that give the consumer everything they need in one response:
// Implementation-focused (bad for consumers)
declare function createUser(row: { id: string; email: string; password_hash: string; created_at: Date }): void;
// Consumer-focused (good)
declare function createUser(
input: { email: string; password: string }
): Promise<Result<{ id: string; email: string }, "EmailTaken">>;The consumer-focused version hides internal fields like the password hash and timestamp, asks only for what it needs, and returns a Result that documents the one expected failure mode.
Use a Controlled Public Surface
Everything you export from your package is part of your public API. A consumer can import it, depend on it, and break when you change it. The barrel file that defines your package's public surface should be deliberate and reviewed on every change.
Export only what consumers need. Internal types used in your implementation should never appear in the public exports. Use a separate internal path for types shared within your package:
// index.ts -- public API, reviewed on every change
export { createUser } from "./user-service";
export type { CreateUserInput, CreateUserResult } from "./user-types";
// internal.ts -- NOT exported from the package entry point
export type { UserRow } from "./user-repository";For more on controlling public surfaces, see avoid type leaks in large TypeScript projects.
Test Your API as a Consumer Would
The best way to find API design problems is to use your own API from the outside. Write a test file that imports only your public exports and tries to accomplish realistic tasks. If the test requires a type assertion to compile, your API is too loose or too hard to use correctly.
Pay attention to friction points. If setting up a function call requires five lines of type construction, consider whether a simpler overload or a builder pattern would help. If a common error case requires an instanceof check, consider whether a discriminated union would be clearer.
Common Mistakes
Exporting types that match database schemas. Consumers should never see column names, internal IDs, or storage format details. Map database types to consumer-friendly types at the boundary.
Using any in public return types. A function that returns any tells the consumer nothing. If the return type is truly dynamic, return unknown and let the consumer validate it.
Changing function signatures in minor versions. Adding an optional parameter is fine. Changing a return type, removing a parameter, or narrowing a type parameter is breaking. Err on the side of a major version bump.
Exposing internal utility types. If a type exists only to help your implementation, it should not appear in the public barrel file. Every exported type is a commitment to support it.
For more on designing types that survive long-term maintenance, see design domain models in TypeScript.
Rune AI
Key Insights
- Export the narrowest types that satisfy the use case; avoid string where a union of three literals works.
- Use branded types for primitives that carry semantic meaning, like UserId or EmailAddress.
- Return Result types instead of throwing so callers see failure modes in the return signature.
- Export only the public API through a controlled barrel file; keep internal types internal.
- Test your API from a consumer's perspective: write example usage in a separate package that only imports the public exports.
Frequently Asked Questions
Should I export interfaces or type aliases in a public TypeScript API?
How do I version a public TypeScript API?
Conclusion
A well-designed public TypeScript API makes the correct usage obvious and the incorrect usage impossible. Narrow types, branded primitives, Result patterns, and deliberate public surfaces turn your library into something consumers can trust. When the compiler catches misuse before the code runs, your documentation burden shrinks and your API earns adoption.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.