API Design
The difference between an internal API and a public one is that you can fix an internal one on a Tuesday. Every public symbol is a promise you will be asked to keep for years.
The core discipline: export less
Everything exported is a maintenance obligation. Everything private is free.
- Start with the smallest surface that solves the problem. Adding an export later is a minor release; removing one is a major release plus a migration guide.
- Explicit export lists.
export { a, b } from './x'— neverexport *, which silently promotes every future internal symbol into your public API. - Mark internals unmistakably.
_private,internal/directories,#[doc(hidden)],@internal. Users will still reach in; the marking is what makes it their problem when it breaks. - Audit before 1.0. List every exported symbol and justify each one out loud. In most codebases this deletes 30–50% of the surface, and none of the deletions are ever missed.
# TypeScript: see the real public surface
npx api-extractor run --local
# Python: what does `from pkg import *` actually give?
python -c "import pkg; print([s for s in dir(pkg) if not s.startswith('_')])"
# Rust
cargo public-api
# Go
go list -f '{{.Name}}: {{.GoFiles}}' ./...
Wire a public-API snapshot into CI. A diff in that snapshot forces a conscious decision at review time instead of an accidental breaking change at release time.
Function signatures
Arguments. More than three positional parameters, or any boolean parameter, means
switch to an options object / keyword arguments. connect(host, 5432, true, false) is
unreadable at the call site and impossible to extend without breaking.
// Bad — unextendable, unreadable at call site
connect('db.local', 5432, true, false, 30)
// Good — self-documenting, additive
connect({ host: 'db.local', port: 5432, tls: true, timeoutMs: 30_000 })
Defaults. Every option gets a sensible default. The zero-config path must work for the common case; configuration is for the exceptions. If your quickstart needs six options set, the defaults are wrong.
Return types. Consistent shapes. A function that returns T | undefined | T[]
depending on input forces every caller to write a type guard. Pick one.
Naming. Boring and predictable beats clever:
- Match ecosystem convention (
camelCasein JS,snake_casein Python/Rust,MixedCapsin Go) - Verb-first for actions (
parseConfig), noun for accessors (config) - Say what it returns, not how it works:
getUserByIdnotqueryUserTable - Same concept, same word, everywhere. Not
removehere anddeletethere. - No abbreviations except universal ones (
id,url,db).cfgMgrcosts every reader a lookup, forever.
Errors are API
Error behavior is part of your contract, and it is the part users hit under stress.
- Typed, distinguishable errors. Users must branch on failure mode without regex on message strings — because if you leave them no choice, they will regex on message strings, and then your messages are frozen too.
- Actionable messages. State what failed, what was expected, and what to do:
Config error at config.yml:12 — 'timeout' must be a number, got "30s". Use 30000. - Never swallow errors. No empty
catch. No returningnullfor "something broke". - Distinguish programmer errors from operational errors. A bad argument is a bug to be thrown loudly; a network timeout is a condition to be returned and handled.
- Include a cause chain.
throw new Error('msg', { cause }),raise ... from e,fmt.Errorf("...: %w", err). Stripping the cause destroys debuggability.
Extension points
Users will need behavior you did not anticipate. Decide deliberately how they get it, because if you provide no seam they will monkey-patch your internals and you will break them accidentally.
| Mechanism | Good for | Cost |
|---|---|---|
| Callbacks / hooks | Single well-known extension point | Ordering and error semantics get subtle |
| Plugin interface | Many independent extensions | Versioning the plugin contract |
| Middleware chain | Composable request/response pipelines | Debuggability suffers |
| Events | Observation, logging, metrics | Not suitable for control flow |
| Subclassing | Rarely the right answer | Every internal method becomes public API |
Prefer composition over inheritance in public APIs. Inheritance makes every protected member a compatibility obligation.
Async and concurrency
- Do not mix paradigms. Callback and promise variants of the same function double the surface and the bug surface. Pick one; if you must support both, generate one from the other.
- Make cancellation possible from day one —
AbortSignal,context.Context,CancellationToken. Retrofitting cancellation is a breaking change. - Never block the main thread in a library. Users cannot work around it.
- Do not create global state or start background work at import time. A library
that opens a connection pool on
importis unusable in a serverless function and untestable everywhere.
CLI design
A CLI is an API where the users are humans and shell scripts, simultaneously.
<tool> <noun> <verb>for multi-command tools (git remote add,docker image ls). Consistent, discoverable, extensible.- Long flags always; short flags for the top five only.
--verbosewith-v. --helpat every level, with examples. Most users never open the docs site.- Respect
--json. Machine-readable output turns your tool into a building block. Also: detect whether stdout is a TTY and disable colors/spinners when piped. - Exit codes matter.
0success,1general failure,2usage error. Scripts depend on these. - stdout is data, stderr is chatter. Progress bars and logs on stderr, always.
- Confirm destructive actions, with
--yes/--forceto skip in automation. - Never require interactivity in a non-TTY. A prompt in CI is a hang.
Config precedence, highest to lowest — deviate from this and users will be confused
forever: CLI flag → environment variable → project config file → user config file →
built-in default. Document it, and provide a way to show the resolved config
(tool config show --explain).
Stability guarantees
State the contract explicitly in the README or docs. Users cannot infer it.
## Stability
- Everything exported from the package root is public API under semver.
- Anything under `internal/` or prefixed `_` may change in any release.
- `experimental_*` exports may change in minor releases.
- Node 20+ and Python 3.10+ supported; dropping a runtime is a major release.
Decide and document whether these count as breaking: adding a required config field (yes), changing an error message (no) vs an error type (yes), tightening input validation (usually yes in practice), changing output ordering (yes if unsorted output was documented as stable), and raising the minimum runtime version (yes).
Deprecation
Never delete without a deprecation cycle. The full sequence:
- Mark it —
@deprecated/#[deprecated]/warnings.warn(DeprecationWarning), with the replacement named in the message. - Warn at runtime, once per process, not per call. A warning in a hot loop gets silenced globally and then nobody sees any warnings.
- Document the removal version in the message and the changelog.
- Provide the migration path — ideally a codemod (
jscodeshift,libcst,comby). - Wait at least one minor release, ideally two, and honor the stated timeline.
- Remove in a major release, listed in the migration guide.
/** @deprecated Use `createClient()` instead. Removed in v4.0.0. */
export function makeClient(opts: Options): Client {
warnOnce('makeClient() is deprecated; use createClient(). Removed in v4.0.0.');
return createClient(opts);
}
Reviewing an API proposal
Questions worth asking on every new public symbol:
- What is the smallest version of this that solves the reported problem?
- Can a user already do this by composing existing pieces? (If yes, document that.)
- What does the call site look like? Write it before writing the implementation.
- How does this fail, and what does the user see?
- What is the migration path when we get this wrong?
- Does it match the naming and shape of everything nearby?
- Does it force a dependency on users who will never call it?
The best answer to a feature request is often a documentation page. The second best is an extension point. A new public function is third.
Anti-patterns
export *from the package root.- Boolean parameters.
render(true)— nobody knows what that does. - Config objects with 40 options. Symptom of unmade decisions; split the API.
- Stringly-typed inputs.
mode: 'fast' | 'safe'beatsmode: string. - Leaking dependency types in your public signatures. Now their major version is your major version.
- Different names for the same concept across the surface.
- Silent behavior change in a patch release. This is the trust-destroying one.
- Designing for hypothetical users. Build for the requests you actually have.