Design the public API
A package contract is the set of imports, values, types, errors, and behavior that consumers can rely on.
Write the desired consumer call first
import {
getQuestions,
QuizApiError,
type Question,
} from "@your-scope/quiz-sdk";
This one import defines a useful goal. Consumers should not need to know your folder names, contract package, schema library, HTTP handler, or test helpers.
Use one deliberate entry point
Export the supported API from src/index.ts:
export { getQuestions, QuizApiError } from "./get-questions.js";
export type {
GetQuestionsInput,
GetQuestionsOutput,
Question,
} from "@your-scope/quiz-contracts";
The entry point is a gate. A file that is not exported can change without becoming a documented promise. Avoid imports such as @your-scope/quiz-sdk/src/url-builder. They couple users to your folders.
Name input, output, and errors
export type GetQuestionsInput = {
category: "science" | "history" | "geography";
count: number;
difficulty?: "easy" | "medium" | "hard";
includeAnswer?: boolean;
};
export type GetQuestionsOutput = {
questions: Question[];
};
Named types make documentation and error messages easier to read. They also give tests stable vocabulary. Avoid returning anonymous objects whose meaning must be rediscovered at every call site.
Let options affect return types when the server behaves that way
If includeAnswer: true makes the API return answers, overloads can express that relationship:
export function getQuestions(
input: GetQuestionsInput & { includeAnswer: true },
config: QuizSdkConfig,
): Promise<GetQuestionsOutputWithAnswers>;
export function getQuestions(
input: GetQuestionsInput & { includeAnswer?: false },
config: QuizSdkConfig,
): Promise<GetQuestionsOutput>;
This is valuable only because runtime behavior differs. Do not add generic types or overloads for decoration. Every type feature should prevent a real consumer mistake.
Inject environment-specific behavior
export type QuizSdkConfig = {
baseUrl: string;
fetch: typeof globalThis.fetch;
};
Accepting fetch makes the SDK work with the consumer's runtime and gives tests a clean replacement. The SDK does not need a global mutation or an internal network mock.
Convenience can come later
You can add a createQuizClient(config) helper if users make many calls with the same configuration. Start with one plain public function until repeated usage proves the need.
Make expected failures inspectable
export class QuizApiError extends Error {
readonly status: number;
readonly code: GetQuestionsError["code"];
constructor(status: number, error: GetQuestionsError) {
super(error.message);
this.name = "QuizApiError";
this.status = status;
this.code = error.code;
}
}
A consumer can catch QuizApiError and branch on status or code. Plain message strings are useful for humans but brittle for program logic.
Know what becomes a compatibility promise
- Exported function names and parameter types.
- Return values and rejected error classes.
- Whether fields are required, optional, or forbidden.
- Documented side effects and network behavior.
- Export paths such as the package root or a named subpath.
Renaming a private helper is internal. Removing an exported field is a breaking release even if all repository tests still compile after you update them.
Check your understanding
Why re-export contract types from the SDK?
Show answer
Consumers can install and import from one package. The contracts package remains a transitive runtime dependency instead of becoming a second package users must understand.
When is an overload justified?
Show answer
When a concrete input choice changes the runtime output and the compiler can represent that relationship. An overload that does not mirror behavior adds complexity without safety.