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

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

  1. 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.

  2. 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.