Designing a Library's Public API
Expert · 20 min read · ▶ live playground · ✦ checkpoint
Designing a library's public API means deciding what other developers are allowed to import, call, and depend on. Your exported TypeScript types are not decoration; they are promises consumers compile their code against.
A library is code meant to be reused by other projects. A public API is the exported values and types consumers are meant to use. Once you publish them, every parameter type, return type, property name, and import path becomes part of the product.
The hard part is not making a clever type. The hard part is making a promise you can keep.
Exported types are product promises
Here is the public edge of a tiny launch-planning library. It uses three patterns you already know: a union for one shared behavior, overloads for a few call shapes, and a generic for preserving a caller's exact item type.
→ draft Search launch for pro needs 10 karma
→ launch_search is live for pro
→ Mina owns Search launch
That code is not using exports because the playground runs one file. In a real library, the exported version of Plan, LaunchDraft, PublishedLaunch, describeLaunch, makeLaunch, and findById is the public contract.
Pick the smallest public shape
An overload is one implementation with several public call signatures. Use overloads when a few input shapes produce different return types, like makeLaunch("draft", ...) returning LaunchDraft and makeLaunch("published", ...) returning PublishedLaunch.
A union is a value that may be one of several allowed types. Use a union when the operation is the same for every member and the return type is the same, like describeLaunch(launch: Launch): string.
A generic is a type parameter that links positions in a signature. Use a generic when the caller's exact type should flow through, like findById<T>(items: T[], id: string): T | null.
The decision rule is plain:
- Use a union for closed choices with one behavior.
- Use overloads for a small set of distinct call shapes.
- Use a generic when the caller's input type must be preserved.
If you can write a boring union, do that. If you need two or three precise call shapes, overloads are readable. If the function must work for many caller-owned shapes, reach for a generic. A public type that tries to impress readers is usually harder to support than one that tells the truth.
Hide internals on purpose
An internal API is code your package uses but consumers should not depend on. Hiding internals has two layers: don't export them from your public entry point, and don't expose import paths that lead to them.
Shown as read-only reference code:
export type Plan = "free" | "pro";
export type LaunchDraft = {
kind: "draft";
name: string;
plan: Plan;
karmaRequired: number;
};
/** @public */
export function createLaunchPlan(name: string, plan: Plan): LaunchDraft {
return { kind: "draft", name, plan, karmaRequired: 10 };
}Shown as read-only reference code:
import type { Plan } from "../index.ts";
/** @internal */
export function _normalizePlan(value: string): Plan | null {
return value === "free" || value === "pro" ? value : null;
}An export map is the package.json exports field that lists supported import paths. Think of it as the front door list for your package: if a path is not on the list, consumers should not enter through it.
Shown as read-only reference code:
{
"name": "launch-kit",
"type": "module",
"types": "./dist/launch-kit-public.d.ts",
"exports": {
".": {
"types": "./dist/launch-kit-public.d.ts",
"default": "./dist/index.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
}
}
}TSDoc is a documentation-comment standard for TypeScript APIs. The @internal tag marks an API item as not planned for third-party use; tooling can trim it from public declarations, but the tag is not a runtime hiding mechanism. API Extractor is a build-time tool that can analyze exported declarations, produce API reports, and generate declaration rollups that trim release-tagged items.
Shown as read-only reference code:
{
"mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts",
"apiReport": {
"enabled": true,
"reportFolder": "<projectFolder>/etc"
},
"dtsRollup": {
"enabled": true,
"untrimmedFilePath": "<projectFolder>/dist/launch-kit.d.ts",
"publicTrimmedFilePath": "<projectFolder>/dist/launch-kit-public.d.ts"
}
}That config is not required for every small library. The design habit is required: decide what is public, route consumers through those entry points, and keep internal helper names out of casual imports.
Deprecate before you remove
Deprecation is the warning period where an API still works but is no longer the recommended path. JSDoc is a comment format TypeScript and editors can read for API documentation. The @deprecated tag lets you point users toward the replacement before a future removal.
Shown as read-only reference code:
import type { LaunchDraft, Plan } from "./index.ts";
import { createLaunchPlan } from "./index.ts";
/**
* @deprecated Use createLaunchPlan(name, plan) instead.
*/
export function createLaunch(name: string, plan: Plan): LaunchDraft {
return createLaunchPlan(name, plan);
}Deprecation is a detour sign, not a locked gate. Keep the old function working while you give users the new name, the migration path, and enough time to move.
Next, you ship the artifact people actually install. The same public promises have to survive build output, packed files, declarations, and versioning.
Checkpoint
Answer all three to mark this lesson complete