Course outline

TypeScript / Advanced / Lesson 13

Public library design and versioning

Design a consumer-facing contract with controlled construction, explicit failures, accurate declarations, and a compatibility policy broader than a local successful build.

About 5 min readContent reviewed
On this page

Lesson goals

  • Choose public ownership, mutation, and error contracts.
  • Use validated construction without assertion-based branding.
  • Review declaration and package changes from consumer code.

A public type is a commitment to callers

A library interface should explain accepted inputs, output ownership, mutation, timing, errors, and compatibility. Static signatures describe only part of that behavior. A readonly parameter can restrict your access path while still sharing mutable storage with the caller. A callback may be retained or invoked synchronously; consumers need to know which. Avoid exporting every internal helper or derived key union, because future implementation changes can become public breaking changes. Prefer domain names for common results and errors so consumers can understand obligations without expanding a large conditional type or reading internal module code.

Establish domain validity through a real constructor path

The first class permits construction only through a validating static method in checked TypeScript. A private constructor is a compile-time access restriction, while the #value field has JavaScript private-field behavior at runtime. The factory checks a safe integer range before creating the value. Its result makes expected rejection explicit instead of fabricating a branded number through an assertion. Public JavaScript entry points still need the same actual checks; a TypeScript-only private constructor should not be mistaken for a security boundary. The validated factory is the supported creation contract for this example.

typescript A checked factory with explicit rejection
export {};
class Count {
  #value: number;
  private constructor(value: number) { this.#value = value; }
  static create(value: number): Count | undefined {
    return Number.isSafeInteger(value) && value >= 0 && value <= 1000 ? new Count(value) : undefined;
  }
  value(): number { return this.#value; }
}
console.log(Count.create(42)?.value(), Count.create(1001) === undefined);
Expected output
42 true

Forty-two passes the runtime rules. One thousand one is rejected before an instance is created.

Expected failure deserves a structured result

A discriminated result union can separate normal failure from thrown exceptions. The second function accepts only two runtime strings and returns a stable unsupported category otherwise. A library may choose exceptions for other operations, but it should document when callers receive a result and when execution can throw. Adding a result variant can affect exhaustive consumers, and changing error wording should not be the only way callers classify a failure. The actual parser supplies evidence for the returned literal mode; no type assertion is needed. Unknown objects would require additional validation before this string boundary.

typescript A stable runtime error category
export {};
type Mode = "fast" | "safe";
type Result<T> = { ok: true; value: T } | { ok: false; error: "unsupported" };
function parseMode(text: string): Result<Mode> {
  if (text === "fast" || text === "safe") { return { ok: true, value: text }; }
  return { ok: false, error: "unsupported" };
}
console.log(JSON.stringify(parseMode("safe")));
console.log(JSON.stringify(parseMode("other")));
Expected output
{"ok":true,"value":"safe"}
{"ok":false,"error":"unsupported"}

The accepted string becomes a validated Mode. Other text returns the documented failure alternative.

Ship declarations that match the actual package

Declaration generation can describe exported TypeScript APIs, but package metadata must still route consumers to the right JavaScript and declaration entry points. Test ESM and CommonJS only when the package claims to support them. A packed-artifact consumer can reveal missing files, wrong exports, or accidental source-only paths. Document the minimum supported compiler and runtime independently, because declaration syntax and emitted JavaScript have different compatibility constraints. Avoid exposing unstable internal dependency types unnecessarily. A small public type can reduce both coupling and consumer checker cost while preserving the behavior callers need.

Version changes by their observable effect

Changing accepted parameter types, callback variance, nullability, overload selection, inference, or an exported union can break consumers even when the library source still checks. Runtime changes to mutation, defaults, serialization, or error categories also matter. Keep representative consumer fixtures and declaration checks alongside runtime tests. A compiler upgrade can reveal existing defects or alter inference, so compare diagnostics and behavior before raising the supported baseline. Do not promise semantic-version compatibility merely because a package version number was incremented. State deprecations and migration examples concretely, and preserve old behavior only when it remains safe and intentional.

Common errors and fixes

A private constructor is treated as authorization.

Why it happens
Compile-time access was confused with a runtime trust boundary.
How to fix it
Enforce actual checks at public runtime entry points.
Prevent it next time
Document TypeScript privacy and JavaScript private fields separately.

A declaration package loads no implementation.

Why it happens
Publishing checked types was mistaken for publishing runtime files.
How to fix it
Verify packed JavaScript and declaration exports together.
Prevent it next time
Test through a separate package consumer.

A new union variant breaks exhaustive callers.

Why it happens
Exhaustiveness was part of the exposed contract.
How to fix it
Plan a compatible design or explicit migration.
Prevent it next time
Review exported types from downstream calls.

Check your understanding

1. Classify Count.create(0), Count.create(2.5), and Count.create(1000). Which part enforces the domain at runtime?

Show worked solution

Zero and one thousand create values; the fraction returns undefined. Number.isSafeInteger and the explicit range comparisons perform the runtime enforcement. The Count annotation and private constructor alone do not inspect an external number. Consumers must also handle factory rejection before relying on a created instance. Test both inclusive endpoints and fractional values.

2. A library changes parseMode to throw for unsupported input instead of returning an error result. Explain why identical success output does not prove compatibility.

Show worked solution

Callers handling the ok discriminator no longer receive the documented failure value and may now experience an uncaught exception. The failure channel is observable API behavior. Add rejection-path consumer tests and treat the change under the library compatibility policy, with a deliberate migration if required. Checking only safe input misses this contract break.

Key takeaways

  • Design public behavior beyond signatures.
  • Validate construction and expose explicit failure.
  • Test declarations and runtime artifacts as consumers.

Official references