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.

8 min read

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:

typescripttypescript
// 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:

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
// 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:

typescripttypescript
// 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

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.
RunePowered by Rune AI

Frequently Asked Questions

Should I export interfaces or type aliases in a public TypeScript API?

Type aliases are generally preferred for public APIs because they are more predictable. Interfaces support declaration merging, which can accidentally extend your public types when consumers add their own declarations in the same global scope. Use interfaces only when you intentionally want to allow augmentation.

How do I version a public TypeScript API?

Follow semver and let the compiler enforce it. Adding a new optional parameter is a minor bump. Changing a return type or removing an export is a major bump. TypeScript itself cannot enforce semver, but tools like API Extractor can detect breaking changes in your public API surface automatically.

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.