Learning path · Level 8 · Tutorial 3

Type Function Boundaries and Callbacks

Make function boundaries explicit, type callbacks without unnecessary annotations, and describe the results of asynchronous operations.

  • Level: Intermediate
  • Duration: 18 min
  • 8 steps
Start tutorial Back to the path
Type Function Boundaries and Callbacks

What you will go through

  1. Define What a Function Accepts Annotate primitive and object parameters so a function accepts the intended arguments. 2 min
  2. Choose How to Express the Return Type Choose between return type inference and an explicit output contract, then use an explicit annotation to protect a maintained function boundary. 2 min
  3. Locate and Repair Boundary Errors Use compiler diagnostics to distinguish an invalid call from an incompatible returned value, then repair the correct side of each function boundary. 2 min
  4. Name a Callback Contract Name a formatting callback contract, pass it to a consumer, and check that concrete callbacks honor its input and output boundary. 3 min
  5. Let Context Type Your Callbacks Use contextual typing to transform Product[] into string[] without redundant callback annotations. 3 min
  6. Use void When the Caller Ignores the Result Choose void for result-ignoring callbacks and distinguish callback compatibility from an explicit void implementation annotation. 2 min
  7. Describe an Asynchronous Result Annotate an async function with Promise<Product[]> and distinguish the promise returned by a call from the Product[] available after await. 2 min
  8. Apply the Contracts to a Catalog Pipeline 3 min

What you will learn

  • Annotate function parameters and choose between inferred and explicit return types.
  • Identify incompatible arguments and returned values from compiler diagnostics.
  • Declare a callback contract and use contextual inference in array transformations.
  • Describe an asynchronous function's result with Promise<ResultType> and identify the type obtained after awaiting it.

Before you start

  • Define Reusable Object and Array Types
  • Write Functions with Parameters and Lexical Scope
  • Transform Arrays with Callback Functions
  • Sequence Asynchronous Work with async/await

Step 1 of 8

Define What a Function Accepts

Annotate primitive and object parameters so a function accepts the intended arguments.

See the input boundary

Parameters define accepted inputs

Parameter annotations set the input boundary

A parameter annotation states which values a caller may supply to a function. At a call site, each argument is checked against the parameter in the same position.

A standalone function declaration needs its own parameter annotations when no surrounding context supplies them. Calls written later do not infer parameter types back into the declaration.

Use a primitive type for one value, such as a price. Use a named object type such as Product when a function needs a complete product record.

Arguments cross a typed boundary

Diagram connecting numeric arguments to number parameters and a complete product record to a Product parameter.

Each argument is checked against the annotated parameter in the corresponding position.

Write the accepted input types

Annotate primitive and object inputs

The annotation follows the parameter name. calculateTotal accepts two numeric inputs. formatProduct accepts one Product, so its caller must provide a record with all required id, name, and price properties.

Parameter names describe a value's role; annotations describe its permitted type. Argument order still matters: the first argument reaches the first parameter.

Parameter annotation examples

Two functions with different input contracts.

typescript
type Product = { id: string; name: string; price: number };

function calculateTotal(price: number, quantity: number) {
  return price * quantity;
}

function formatProduct(product: Product) {
  return `${product.name}: $${product.price.toFixed(2)}`;
}

Tip

Keep the full object contract

A Product parameter does not mean “any object with a name.” The complete required record shape is the input contract. TypeScript checks that contract before the function body runs.

Run a typed local example

Practice in your local project

For this practice, make src/function-boundaries.ts contain the complete module below. It uses the shared Product shape and reports results in the browser console.

Add typed function inputs

src/function-boundaries.ts

typescript
export {};

type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: 'p1', name: 'Notebook', price: 6 },
  { id: 'p2', name: 'Pen', price: 2 }
];

function calculateTotal(price: number, quantity: number) {
  return price * quantity;
}

function formatProduct(product: Product) {
  return `${product.name}: $${product.price.toFixed(2)}`;
}

console.log('Notebook total:', calculateTotal(products[0].price, 2));
console.log(formatProduct(products[0]));
Expected output
Notebook total: 12
Notebook: $6.00

After starting Vite and opening the browser console, these are the expected console lines.

Load the practice module

src/main.ts

typescript
import './function-boundaries.ts';

Tip

Check types, then observe behavior

From the existing project root, run npx tsc --noEmit to check the parameter contracts. Then run npm run dev and inspect the browser console. A successful compiler check normally produces no diagnostics; the console shows the JavaScript result.

Apply the input contracts

Complete compatible function boundaries

Complete the parameter annotations and the two calls so both calls are compatible. For each call, use the named value declared immediately above it.

typescript
type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: 'p1', name: 'Notebook', price: 6 },
  { id: 'p2', name: 'Pen', price: 2 }
];

const notebookPrice = 6;
const notebook = products[0];

function calculateTotal(price: , quantity: ) {
  return price * quantity;
}

function formatProduct(product: ) {
  return `${product.name}: $${product.price.toFixed(2)}`;
}

const total = calculateTotal(, 2);
const label = formatProduct();

console.log(total, label);

Step 2 of 8

Choose How to Express the Return Type

Choose between return type inference and an explicit output contract, then use an explicit annotation to protect a maintained function boundary.

See the two return-type choices

Output can be inferred or declared

A parameter annotation defines what may enter a function. A return type describes what leaves it.

When a function has no declared return type, TypeScript uses return type inference: it examines the returned expression and derives the result type. Multiplying two number parameters produces a number result.

Writing : number after the parameter list makes the intended output contract visible without requiring a reader to inspect the body.

Inference and an output contract

Diagram showing two numeric inputs entering a multiplication function and a numeric result leaving through either an inferred path or an explicit contract-check path.

Inference follows the implementation; an explicit annotation states the intended result and checks the implementation against it.

A numeric result inferred from the body

Without an annotation, TypeScript infers the result type from the multiplication expression.

typescript
function calculateTotal(price: number, quantity: number) {
  return price * quantity;
}

const total = calculateTotal(6, 2);

Make the intent visible when it matters

Choose based on the boundary

For a straightforward internal helper, return type inference keeps code concise while preserving a precise result type.

Use an explicit return annotation when a function has a deliberate boundary you want to preserve while its implementation changes. The annotation is a check, not a conversion: : number does not turn a returned string into a number. TypeScript instead reports that the implementation does not meet its stated contract.

Inference and an explicit contract

Both approaches provide type information. The difference is where the output expectation is expressed.

Return-type strategies
ApproachType sourceUseful whenWhat TypeScript checks
Return type inferenceReturned expressionsA simple internal helperThe result derived from the body
Explicit `: number`Declared function boundaryA maintained output requirementEach returned value fits `number`

Make the total boundary explicit

Replace src/function-boundaries.ts with this version. calculateTotal now has an explicit numeric output contract, while formatProduct retains an inferred string result. Keep src/main.ts loading this module.

typescript
export {};

type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: 'p1', name: 'Notebook', price: 6 },
  { id: 'p2', name: 'Pen', price: 2 }
];

function calculateTotal(price: number, quantity: number): number {
  return price * quantity;
}

function formatProduct(product: Product) {
  return `${product.name}: $${product.price.toFixed(2)}`;
}

console.log('Notebook total:', calculateTotal(products[0].price, 2));
console.log(formatProduct(products[0]));
Expected output
Notebook total: 12
Notebook: $6.00

A successful `npx tsc --noEmit` normally has no output. With `npm run dev` running, inspect the browser console for these lines.

Keep the module import

Ensure the Vite entry module loads the practice file.

typescript
import './function-boundaries.ts';

Choose deliberately

Judge the strategy

formatProduct is a straightforward helper used only inside one module. calculateTotal is a maintained boundary whose numeric output requirement should remain clear during later edits. Which strategy is best?

Explain and write the boundary

Apply both return-type strategies

Respond with three labeled lines:

  1. For function getDefaultQuantity() { return 2; }, state the inferred return type.
  2. Write the complete declaration header for calculateTotal with its two typed parameters and an explicit numeric return annotation.
  3. Choose a strategy for the simple internal helper versus the maintained total boundary, and give one brief reason for each choice.

Write at least 120 characters (0/120).

Step 3 of 8

Locate and Repair Boundary Errors

Use compiler diagnostics to distinguish an invalid call from an incompatible returned value, then repair the correct side of each function boundary.

Locate the failing side

Read the diagnostic location first

A TypeScript diagnostic tells you which side of a function boundary broke its contract.

  • At a call site, an argument conflicts with its corresponding parameter type. Repair the value supplied by the caller.
  • At a return statement in a function with an explicit return annotation, the returned expression conflicts with that output contract. Repair the implementation's result.

Read three details together: the diagnostic location, the expected type, and the received type. Keep a parameter or return annotation when it expresses the intended requirement; do not weaken the contract just to silence an error.

Two locations for a boundary error

A diagram contrasts a mismatched argument at a call site with a mismatched returned value inside a function.

The diagnostic location identifies whether the caller or the implementation must change.

Compare the failures

Use the location and the expected and received types to choose a repair that preserves the requirement.

Both examples involve an expected number and a received string, but they fail on different sides of the boundary.
Diagnostic locationExpected typeReceived typeRepair focus
At a function callnumber parameterstring argumentCaller supplies a number
At a return statementnumber return typestring expressionImplementation returns a number

Classify the diagnostic

Match diagnostics to repairs

Match each diagnostic summary to the boundary that needs repair.

Tap an item and then its matching pair.

Repair and verify locally

Diagnose, then replace the file

First, inspect this diagnostic-only snippet. It intentionally contains two errors, so do not run it. The first diagnostic is located at the call because a string argument is supplied to a number parameter. The second is located at return because the declared number output contract receives a string expression.

Then replace the entire contents of src/function-boundaries.ts with the repaired file shown below. Keep the existing import in src/main.ts.

Two intentional boundary errors

Diagnostic snippet only — do not run

typescript
function calculateTotal(price: number, quantity: number): number {
  return '$12.00';
}

const notebookTotal = calculateTotal('6', 2);

Repaired executable example

src/function-boundaries.ts — replace the entire file before running

typescript
export {};

type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: 'p1', name: 'Notebook', price: 6 },
  { id: 'p2', name: 'Pen', price: 2 }
];

function calculateTotal(price: number, quantity: number): number {
  return price * quantity;
}

const notebookTotal = calculateTotal(products[0].price, 2);

console.log('Notebook total:', notebookTotal);
Expected output
Notebook total: 12

After `npx tsc --noEmit` succeeds, run `npm run dev` and inspect the browser console.

Keep the module loaded

src/main.ts

typescript
import './function-boundaries.ts';

Tip

Verify the repaired boundary

Run npx tsc --noEmit from the existing project root. A successful compiler check normally produces no diagnostics. Then use npm run dev and compare the browser-console line with the output shown on the repaired code item.

The compiler confirms that the boundaries agree; the console confirms the JavaScript behavior.

Apply the diagnostic evidence

Repair both sides of the boundary

Complete both repairs while retaining the displayed parameter and return contracts. Use the named variable as the argument at the call site.

typescript
type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: 'p1', name: 'Notebook', price: 6 },
  { id: 'p2', name: 'Pen', price: 2 }
];

function calculateTotal(price: number, quantity: number): number {
  return ;
}

const notebookPrice = products[0].price;
const notebookTotal = calculateTotal(, 2);

console.log('Notebook total:', notebookTotal);

Step 4 of 8

Name a Callback Contract

Name a formatting callback contract, pass it to a consumer, and check that concrete callbacks honor its input and output boundary.

Give the callback contract a name

Describe the callback boundary

A function type expression describes a callable value without implementing it. It states the callback’s parameter types and result type.

A named alias lets consumers and implementations share one callback contract:

  • The formatter receives one complete Product.
  • The formatter produces a string.
  • The arrow separates the input from the result.

The alias does not create a function or format anything by itself. It defines which function values are compatible with the ProductFormatter callback contract.

ProductFormatter

The named callback contract

typescript
type ProductFormatter = (product: Product) => string;

Passing a callback is different from invoking it

Diagram showing a Product flowing through formatProduct to make a label, while formatProduct itself is passed into applyFormatter and invoked there.

A caller passes a function as a callback; the consumer invokes that callback later with a Product.

Use the consumer and implementation

Pass the formatter, then invoke it

A callback consumer receives a function value through its formatter parameter. Annotating that parameter with ProductFormatter guarantees that formatter(product) produces a string.

formatProduct is a concrete implementation. It is compatible because its parameter accepts a Product and its returned label is a string. applyFormatter does not need to know how formatting is implemented; it only relies on the callback contract.

Run a complete callback example

src/function-boundaries.ts — replace the entire file

typescript
export {};

type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: 'p1', name: 'Notebook', price: 6 },
  { id: 'p2', name: 'Pen', price: 2 }
];

type ProductFormatter = (product: Product) => string;

function formatProduct(product: Product): string {
  return `${product.name}: $${product.price.toFixed(2)}`;
}

function applyFormatter(
  product: Product,
  formatter: ProductFormatter
): string {
  return formatter(product);
}

for (const product of products) {
  console.log(applyFormatter(product, formatProduct));
}
Expected output
Notebook: $6.00
Pen: $2.00

First run `npx tsc --noEmit` from the existing project root. Then run `npm run dev` and inspect the browser console. Replacing the entire file ensures that no earlier console output, such as a total calculation, remains.

Load the example

Keep the practice module loaded by the Vite entry module.

typescript
import './function-boundaries.ts';

Tip

Check both sides of the contract

Compatibility has two parts:

  • The callback must accept the Product that the consumer may supply.
  • The callback must return the string that the consumer expects back.

Do not weaken the callback contract merely to make an incompatible implementation compile. Preserve what the consumer needs.

Practice the alias

Complete the callback alias

Complete the named formatting callback contract.

typescript
type ProductFormatter = (product: ) => ;

Assess a callback implementation

Apply and check the callback boundary

Write these two declarations from memory, then evaluate the implementation shown.

  1. Write the complete ProductFormatter type alias.
  2. Write the complete header for applyFormatter, including its Product parameter, its formatter parameter, and its string return type.
  3. Is this callback compatible with the formatting contract? State whether the mismatch is in its parameter type or its returned value, and explain briefly.
function priceOnly(product: Product): number {
  return product.price;
}

Write at least 120 characters (0/120).

Step 5 of 8

Let Context Type Your Callbacks

Use contextual typing to transform Product[] into string[] without redundant callback annotations.

Infer types through an inline map callback

A typed array supplies callback context

products has the type Product[]. When an inline function is passed to products.map(...), the surrounding call supplies contextual typing. Its product parameter is therefore treated as Product without repeating product: Product.

The callback returns one formatted string for each product. Because the returned value is a string, map produces a string[]. You may annotate labels: string[] to make that output boundary visible, but the inline parameter already receives its type from the typed array and map call.

From records to labels

Diagram showing product record cards flowing through one formatting callback into label cards collected in an output array.

map contextually types the inline parameter from Product[]; each string result becomes an element of string[].

Transform each product with an inline callback

Replace the entire contents of src/function-boundaries.ts with this focused example. It contains only the contextual-typing practice, so its console output is predictable.

typescript
export {};

type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: 'p1', name: 'Notebook', price: 6 },
  { id: 'p2', name: 'Pen', price: 2 }
];

const labels = products.map((product) => {
  return `${product.name}: $${product.price.toFixed(2)}`;
});

console.log(labels);
Expected output
['Notebook: $6.00', 'Pen: $2.00']

Browser developer tools may display the array with expandable formatting. The two string values are the important result.

Keep the module import

Keep the practice module loaded by the Vite entry module.

typescript
import './function-boundaries.ts';

Keep context when a callback is extracted

Extracted callbacks need a type source

Contextual typing needs a surrounding source of type information. Once a callback is extracted from products.map(...), the later map call cannot supply a type backward into that callback declaration.

Give an extracted callback its own parameter annotation, such as product: Product, or give its variable the established ProductFormatter callback contract. In the contract form, ProductFormatter contextually types product as Product and also requires a string result. Use one clear source of type information; do not repeat both forms unnecessarily.

Where the callback gets its type

An inline callback gets its context from its surrounding call. An extracted callback must establish context where it is declared.

The callback location determines where its parameter type comes from.
Callback locationSource of the Product parameter type
Inline in `products.map`The typed `Product[]` and `map` callback expectation
Extracted functionIts own `product: Product` annotation
Extracted variableIts `ProductFormatter` callback contract

Use ProductFormatter as context

This extracted formatter uses the callback contract from the previous step. The variable annotation supplies contextual typing to the inline arrow function, so its parameter needs no repeated annotation.

typescript
type ProductFormatter = (product: Product) => string;

const formatProduct: ProductFormatter = (product) => {
  return `${product.name}: $${product.price.toFixed(2)}`;
};

Practice contextual typing and extracted callbacks

Practice: trace and repair callback types

Complete the inline formatting callback, identify the contextual parameter type and the resulting array type, then repair the extracted callback with its own parameter annotation.

typescript
const labels:  = products.map((product) => {
  return `${product.name}: $${product.price.}`;
});

type InlineParameter = ;

const formatProduct = (product: ) => {
  return product.name;
};

Step 6 of 8

Use void When the Caller Ignores the Result

Choose void for result-ignoring callbacks and distinguish callback compatibility from an explicit void implementation annotation.

Choose the callback's purpose

Two callback purposes

A transformation callback produces a value for the next step. A result-ignoring callback performs a side effect, such as logging a label that already exists.

void says callers are not intended to use the callback result. Use ProductFormatter when a Product becomes a string. Use LabelSink when a string label is delivered to a side effect.

Follow the value

A comparison diagram where a product becomes a label through a transformation callback, while a label is sent to a console side effect with no value continuing from it.

A formatter produces a string for later use; a label sink receives a string for a side effect.

Callback contracts

These named callback contracts state whether the caller uses the callback result.

typescript
type ProductFormatter = (product: Product) => string;
type LabelSink = (label: string) => void;

Compare assignment with implementation checks

Assignment compatibility differs from an implementation annotation

Compatibility depends on what the caller needs. A function that accepts a string and also returns a number can satisfy LabelSink: the caller invokes it for its side effect and ignores that number.

This differs from explicitly writing : void on an implementation. That annotation checks the implementation itself, so returning a usable string or number is an error. Assigning a function to a void callback contract does not erase its runtime return value.

Run a complete void callback example

Replace the entire contents of src/function-boundaries.ts with this complete example. It declares each callback contract once and logs only the behavior used in this step. Keep src/main.ts loading this module with import './function-boundaries.ts';.

typescript
export {};

type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: 'p1', name: 'Notebook', price: 6 },
  { id: 'p2', name: 'Pen', price: 2 }
];

type ProductFormatter = (product: Product) => string;
type LabelSink = (label: string) => void;

function formatProduct(product: Product): string {
  return `${product.name}: $${product.price.toFixed(2)}`;
}

const measureLabel = (label: string) => {
  console.log('Logged label:', label);
  return label.length;
};

const sendLabel: LabelSink = measureLabel;

function logLabel(label: string): void {
  console.log('Logged label:', label);
  // return label.length; // Error: number is not assignable to void.
}

const label = formatProduct(products[0]);
sendLabel(label);
console.log('Measured length:', measureLabel(label));
Expected output
Logged label: Notebook: $6.00
Logged label: Notebook: $6.00
Measured length: 15

The `sendLabel` call is typed as a void-result callback use. Calling `measureLabel` directly demonstrates that its original runtime number is still available.

Tip

Check types and observe runtime behavior

Run npx tsc --noEmit from the existing project root. After a successful check, run npm run dev and inspect the browser console. The compiler checks the callback contracts; the console demonstrates that the original measureLabel function still returns its number when called directly.

Transformation or side effect?

Compare the contracts by the role of the callback result.

The return position communicates what the caller may use.
ContractInputCaller uses callback result?Result contract
ProductFormatterProductYesstring
LabelSinkstring labelNovoid

Practice: read the contract

Check callback compatibility

True or false: A callback written as (label: string) => label.length can be assigned to a LabelSink variable, even though it returns a number.

Final check: select and justify

Apply both void rules

Write a short response with all four parts:

  1. Complete the full type alias named LabelSink for a label-logging callback.
  2. Choose the contract for a callback that logs an existing label.
  3. Choose the contract for a callback that turns a Product into a catalog label.
  4. Judge both cases as allowed or not allowed, with a brief reason:
  • assigning a string-accepting callback that returns label.length to the label-logging contract;
  • returning label.length from an implementation whose own return annotation is : void.

Write at least 180 characters (0/180).

Step 7 of 8

Describe an Asynchronous Result

Annotate an async function with Promise<Product[]> and distinguish the promise returned by a call from the Product[] available after await.

Declare the fulfilled result

Declare the fulfilled result

State the eventual result

An async function gives its caller a promise. If the value available after fulfillment is a Product[], its explicit return annotation is Promise<Product[]>.

Inside loadProducts, the return statement still supplies the fulfilled value directly: products is a Product[]. The async function wraps that eventual result for its caller.

Promise is a built-in type you can consume here. You do not need to define another type or a reusable generic implementation.

Trace the asynchronous boundary

A diagram showing a Product array entering an async function, a Promise of a Product array at the call, and a Product array after await.

The call produces a promise; await provides its fulfilled Product[] value.

Run a typed local asynchronous source

Replace the entire contents of src/function-boundaries.ts with this self-contained asynchronous example. It uses a local in-memory source, so no network request is involved. Then run npx tsc --noEmit from the project root. To observe the result, run npm run dev and inspect the browser console.

typescript
export {};

type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: 'p1', name: 'Notebook', price: 6 },
  { id: 'p2', name: 'Pen', price: 2 }
];

async function loadProducts(): Promise<Product[]> {
  return products;
}

async function showProducts() {
  const loadedProducts = await loadProducts();
  console.log('Loaded product count:', loadedProducts.length);
}

showProducts();
Expected output
Loaded product count: 2

Browser-console formatting can vary, but confirm that the reported count is 2.

Keep the module loaded

Ensure that the Vite entry module loads the practice file.

typescript
import './function-boundaries.ts';

Practice the return annotation

Complete the async contract

Complete the explicit return annotation for loadProducts.

typescript
async function loadProducts():  {
  return products;
}

Trace types through the call

Before and after await

Match each expression to its type.

Tap an item and then its matching pair.

Apply the asynchronous contract

Final boundary trace

Use the local source contract below for a final boundary trace.

Assume products has type Product[] and loadProducts is an async function with no parameters.

// Implementation A
return products;

// Implementation B
return ['Notebook: $6.00', 'Pen: $2.00'];

Write a response with all three parts:

  1. Write the complete declaration header for loadProducts, including its explicit return annotation.
  2. State the type of loadProducts() and the type of await loadProducts().
  3. Identify implementation A or B as incompatible with the declared fulfilled-result contract, and briefly explain why.

Write at least 120 characters (0/120).

Step 8 of 8

Apply the Contracts to a Catalog Pipeline

See the end-to-end types

Trace the catalog pipeline

Bring the established contracts together in one pipeline. buildCatalogLabels accepts a ProductFormatter to turn each product into a label and a LabelSink to receive each label for a side effect. Its own result is Promise<string[]>.

There are two asynchronous boundaries to trace: loadProducts() is awaited to obtain Product[], then formatting produces string[]. Calling buildCatalogLabels(...) produces a promise; awaiting that call produces the label array.

From products to labels

A flow diagram traces products through an asynchronous source, formatting, labels, and a separate console side effect.

The call returns a promise; await exposes the fulfilled array at the next boundary.

Apply the contracts

Complete the pipeline boundary

Complete the three contract annotations for the catalog pipeline.

typescript
async function buildCatalogLabels(
  formatter: ,
  sink: 
):  {
  const loadedProducts = await loadProducts();
  const labels = loadedProducts.map((product) =>
    applyFormatter(product, formatter)
  );

  labels.forEach((label) => {
    sink(label);
  });

  return labels;
}

Check locally in the compiler and console

Run the local verification

In your existing local project, replace the contents of src/function-boundaries.ts with the complete example below. Ensure that src/main.ts loads it.

From the project root, run npx tsc --noEmit. After it succeeds, run npm run dev, open the app, and inspect the browser console. The compiler confirms the contracts; the console shows the runtime behavior.

Complete catalog example

src/function-boundaries.ts

typescript
export {};

type Product = { id: string; name: string; price: number };

const products: Product[] = [
  { id: 'p1', name: 'Notebook', price: 6 },
  { id: 'p2', name: 'Pen', price: 2 }
];

type ProductFormatter = (product: Product) => string;
type LabelSink = (label: string) => void;

function formatProduct(product: Product): string {
  return `${product.name}: $${product.price.toFixed(2)}`;
}

function applyFormatter(
  product: Product,
  formatter: ProductFormatter
): string {
  return formatter(product);
}

async function loadProducts(): Promise<Product[]> {
  return products;
}

async function buildCatalogLabels(
  formatter: ProductFormatter,
  sink: LabelSink
): Promise<string[]> {
  const loadedProducts = await loadProducts();
  const labels = loadedProducts.map((product) =>
    applyFormatter(product, formatter)
  );

  labels.forEach((label) => {
    sink(label);
  });

  return labels;
}

async function showCatalog(): Promise<void> {
  const labels = await buildCatalogLabels(formatProduct, (label) => {
    console.log(label);
  });

  console.log(labels);
}

showCatalog();
Expected output
Notebook: $6.00
Pen: $2.00
['Notebook: $6.00', 'Pen: $2.00']

The two individual label lines are the required runtime confirmation. Browser developer tools may render the final array with different spacing or an item-count prefix.

Load the example

src/main.ts

typescript
import './function-boundaries.ts';

Close with the checklist

Report the verified pipeline

After running the complete example locally, report all three parts: the two individual label lines you observed in the browser console; the type of the buildCatalogLabels(...) call before await; and the type of the value assigned after awaiting that call.

Write at least 70 characters (0/70).

Summary

Boundary checklist

  • State accepted inputs at each boundary: buildCatalogLabels receives a ProductFormatter and a LabelSink.
  • State the asynchronous output honestly: buildCatalogLabels returns Promise<string[]>.
  • Trace await separately: awaiting the product source gives Product[]; awaiting the catalog call gives string[].
  • Use the compiler for contract confirmation and the browser console for observed runtime labels.
  • Keep the sink separate from transformation: it receives labels for a side effect, while the pipeline still returns the labels array.

Step complete

Congratulations! You completed: Type Function Boundaries and Callbacks

You completed and verified an asynchronous catalog-label pipeline with explicit function, callback, and promise boundaries.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate