Callback Contracts

The Course
•
5 min read

A subscription callback is a consumer of a payload. If the channel promises only title, the callback cannot require subtitle as well. The caller may publish a title-only record. This is the callback safety rule from intermediate, now applied to a public library.

Use function-valued properties in the contract, such as subscribe: (listener: Listener<T>) => () => void. Under strict checking they participate in the parameter compatibility rules we want. Read the syntax as a property named subscribe whose value is a function; its parameter is another function, and its result is a cleanup function. This explicit property form avoids the looser compatibility rules TypeScript gives method declarations. You do not need variance terminology to implement this contract.

A returned cleanup function gives ownership to the subscriber. Calling it twice should be harmless. Two registrations of the same callback must still be independent if that is the documented API. Those are runtime rules, not consequences of the generic T.

Today’s small connection owns one callback and an active flag. Its cleanup actually prevents future delivery. The checkpoint grows it into independent subscriptions, and Day 5 tightens the return contract and adds disposal. Read the contract and behavior separately so a type annotation never substitutes for cleanup code.

Predict, check, and run

Write the output before running. Use Check types first, then Check & run. Types disappear at runtime: explain both what the compiler checks and what the code actually does. Each editor is an independent ES module; await asynchronous work explicitly.

Predict, then runTypeScript
type Listener<T> = (value: T) => void;

function connect<T>(listener: Listener<T>) {
  let active = true;

  return {
    publish(value: T): void {
      if (active) {
        listener(value);
      }
    },
    cleanup(): void {
      active = false;
    }
  };
}

const connection = connect<{ title: string }>(book => {
  console.log(book.title);
});

connection.publish({ title: "Types" });
connection.cleanup();
connection.cleanup();
connection.publish({ title: "Hidden" });

Enable JavaScript to edit and type-check this example.

Check your prediction
Types

Read the compiler error

Predict the rejected relationship before checking. Read the real diagnostic and its source location; fix the contract or the value instead of hiding it with an assertion.

Find and fix the type errorTypeScript
type Book = { title: string };

type RichBook = {
  title: string;

  subtitle: string;
};

function subscribe(listener: (book: Book) => void): void {}

subscribe((book: RichBook) => {
  console.log(book.subtitle);
});

Enable JavaScript to edit and type-check this example.

Check your diagnosis

A subscriber promised Book cannot require the extra subtitle. Accept the public payload contract and only read guaranteed fields.

A callback consumes the promised payload

A subscriber may use only the fields guaranteed by the channel. Accepting a broader input is safe; demanding a richer record than the channel promises is not. Trace the actual value the publisher can send before judging a callback by its name or current fixture.

Return values are another independent part of the contract. Today’s familiar void callback permits ignored returns; Day 5 tightens it for a synchronous channel. Do not assume parameter safety also proves settlement or cleanup behavior.

A callback consumes the promised payload: traceTypeScript
type Book = { title: string };

function announce(book: Book, listener: (value: Book) => void): void {
  listener(book);
}

const richer = { title: "Types", pages: 120 };

announce(richer, book => {
  console.log(book.title);
});

Enable JavaScript to edit and type-check this example.

Check your prediction
Types

The callback accepts the public Book contract even though this one object is richer. A future publisher may provide no pages field, so the listener must not require it.

A callback consumes the promised payload: contrastTypeScript
let active = true;

function publish(value: string, listener: (value: string) => void): void {
  if (active) {
    listener(value);
  }
}

const listener = (value: string) => {
  console.log(value);
};

publish("Types", listener);
active = false;
publish("Hidden", listener);

Enable JavaScript to edit and type-check this example.

Check your prediction
Types

The active flag implements behavior outside the function type. A cleanup return signature alone would not make the second delivery disappear.

Try the rule on new inputs

Try a callback accepting only the guaranteed shape and one requiring an extra field. Then write an observable cleanup test. Keep parameter compatibility, return behavior and lifecycle as three separate questions.

Build it yourself

Exercise

Callback Contracts: practise

Write announce<T>(value:T, listener:(value:T)=>void) and call it with a title-only book. Print Types. Then use the compiler error lab to explain why requiring subtitle is unsafe.

Your workspaceTypeScript
// Start from the stated contract. Add a valid call and an edge case.

Enable JavaScript to edit and type-check this example.

Hint 1: the idea

Inference should determine the payload from the supplied record.

Hint 2: a first step

Invoke the callback with the same T value the function receives.

Show one solution
One possible solutionTypeScript
function announce<T>(value: T, listener: (value: T) => void): void {
  listener(value);
}

announce({ title: "Types" }, book => {
  console.log(book.title);
});

Enable JavaScript to edit and type-check this example.

Types

Trace the implementation against the task before comparing styles. A passing example supports that case; use the changes below to challenge the wider contract.

Change it and test it

Try a callback accepting {title:string} for a richer record, then a callback demanding an extra required field. Explain why accepting fewer guarantees is safe while demanding more is not.

Was this article helpful?

Your response is saved on this device.