Learning path · Level 8 · Tutorial 4

Narrow Unions and Nullable Values

Represent alternative states and safely use values that may be missing or have different shapes, including values obtained from browser APIs.

  • Level: Intermediate
  • Duration: 24 min
  • 8 steps
Start tutorial Back to the path
Narrow Unions and Nullable Values

What you will go through

  1. Describe the Permitted Alternatives Define primitive and literal unions for a product-search interface, then identify why a type-specific operation is unsafe before the union is narrowed. 2 min
  2. Narrow with Checks and Early Returns Use runtime checks and early returns to narrow primitive and literal unions before type-specific operations. 3 min
  3. Separate Missing Values from Available Values Recognize explicit absence and omitted optional properties, then narrow missing alternatives before using an available value. 3 min
  4. Choose Defaults Without Losing Valid Values Use optional chaining and nullish coalescing to handle an absent product selection without replacing valid falsy product values. 3 min
  5. Distinguish Object Shapes with in Narrow a typed object union by checking a required property key, including when that property's value is an empty string. 2 min
  6. Check DOM Presence and Element Type Safely narrow DOM selections by checking both whether an element exists and whether it has the required input subtype. 3 min
  7. Model Interface Outcomes with a Discriminant Model product-search outcomes as a discriminated union and safely use the payload available in each status branch. 4 min
  8. Apply and Verify Safe Narrowing Complete and locally verify a compact product-search interface that safely narrows unions, nullish values, DOM selections, object shapes, and interface states. 4 min

What you will learn

  • Define a union of permitted values and narrow it before performing type-specific operations.
  • Handle optional and nullable values without replacing valid zero, false, or empty-string values unintentionally.
  • Check a DOM selection for absence and its element subtype before reading specialized properties.
  • Model distinct interface outcomes with a discriminated union and access each variant's fields only in the appropriate branch.

Before you start

  • Define Reusable Object and Array Types
  • Type Function Boundaries and Callbacks
  • Read and Update the DOM Safely
  • Render Loading, Empty, Error, and Recovery States

Step 1 of 8

Describe the Permitted Alternatives

Define primitive and literal unions for a product-search interface, then identify why a type-specific operation is unsafe before the union is narrowed.

Declare the alternatives

One value, permitted alternatives

A union lists permitted alternatives for one value. The value can be one alternative at a time; it does not need to be every listed type simultaneously.

In a product search, an identifier might come from a numeric database key or a text-based catalog key. ProductId communicates both permitted forms. A literal union is narrower: ViewMode permits only the exact strings listed.

These aliases reject values outside their contracts, such as true for ProductId or "table" for ViewMode.

A union permits alternatives

Diagram showing a product identifier represented either as a numeric tag or a text tag, alongside a view selector with grid and list alternatives.

Each union value takes one permitted form. Literal unions limit values to an exact, small set.

Product identifiers and view modes

These aliases describe the permitted values for the product-search interface.

typescript
type ProductId = string | number;
type ViewMode = "grid" | "list";

const numericId: ProductId = 101;
const textId: ProductId = "p-102";
const currentMode: ViewMode = "grid";

Respect every remaining alternative

Operations must fit every possibility

TypeScript must protect every remaining possibility in a union. That means an operation is allowed only when it works for all alternatives currently permitted.

For example, toUpperCase() is a string operation. It cannot be called directly on a ProductId, because a ProductId might currently be a number. Conversely, number-specific arithmetic cannot assume the identifier is numeric.

A preceding check will later let TypeScript narrow the possibilities. For now, the key rule is: do not use an operation that only one union member supports while the other member is still possible.

An unsafe operation before narrowing

This function has a type error because the parameter may be a number.

typescript
type ProductId = string | number;

function formatProductId(productId: ProductId): string {
  return productId.toUpperCase();
}

Tip

Why TypeScript rejects it

The declared return type is not the issue. The unsafe expression is productId.toUpperCase(): toUpperCase exists on strings, but not on numbers. TypeScript requires the code to account for the permitted alternatives before using a type-specific operation.

Apply the permitted-value contracts

Practice the declarations

Complete the two aliases so the shown assignments are accepted by TypeScript.

Complete the union aliases

Fill in the permitted alternatives.

typescript
type ProductId =  | ;
type ViewMode =  | ;

const numericId: ProductId = 101;
const textId: ProductId = "p-102";
const currentMode: ViewMode = "list";

Check your understanding

Spot the unsafe operation

Which review of this code is correct?

Step 2 of 8

Narrow with Checks and Early Returns

Use runtime checks and early returns to narrow primitive and literal unions before type-specific operations.

See narrowing as control flow

Narrow a primitive union

A runtime check also gives TypeScript information. Before a guard, ProductId can be either string or number, so an operation must work for both. Inside a branch guarded by typeof id === "string", only the string alternative remains reachable. The other branch contains the number alternative.

This refinement of a union is called narrowing. TypeScript follows the control flow of your conditions rather than changing the value itself.

Follow the remaining alternatives

A flow diagram shows a ProductId union splitting into a string branch and a number branch after a type check.

Each branch has fewer reachable alternatives than the union before the check.

Use equality for ViewMode

Narrow literal alternatives

Equality can narrow a union of literal values in the same way. When mode is checked against one permitted literal, that branch has exactly that literal. Once the grid branch returns, the final return can only be reached when the remaining literal is present.

A ViewMode branch

A strict equality check selects one literal alternative.

typescript
type ViewMode = "grid" | "list";

function getViewLabel(mode: ViewMode): string {
  if (mode === "grid") {
    return "Grid view";
  }

  return "List view";
}

Let the return simplify the rest of the function

Remove an alternative with an early return

An early return makes the remaining type especially clear. If the string path returns immediately, execution can continue only when id is a number. The final line can therefore call a number-specific method safely.

This is control-flow analysis: TypeScript tracks which alternatives are still reachable at each point in the function.

Format either kind of product ID

The first return removes the string alternative from the code below it.

typescript
type ProductId = string | number;

function formatProductId(id: ProductId): string {
  if (typeof id === "string") {
    return id.toUpperCase();
  }

  return `Product #${id.toFixed(0)}`;
}

Tip

Design note

Prefer a guard that makes the later code simpler. Here, the early return avoids nesting the number-specific work in an else block while preserving the same safe narrowing.

Apply the guards and trace control flow

Complete the guard

Complete the condition so that both type-specific operations are safe.

typescript
function formatProductId(id: ProductId): string {
  if ( id  "string") {
    return id.toUpperCase();
  }

  return `Product #${id.toFixed(0)}`;
}

Trace the remaining types

Match each code location to the type TypeScript knows at that location.

Tap an item and then its matching pair.

Step 3 of 8

Separate Missing Values from Available Values

Recognize explicit absence and omitted optional properties, then narrow missing alternatives before using an available value.

Identify the missing alternative

Two kinds of absence

A value can be missing in two different ways under strict null checking:

  • null is an explicit value. In this product search, it means there is deliberately no selected product.
  • undefined occurs when an optional property was omitted.

Both are nullish values: specifically, null or undefined. Before using a value that might be nullish, narrow away its missing alternatives.

Absence versus an available value

A diagram contrasts an explicitly absent selected product, an omitted optional property, and available values including zero, false, and an empty string.

null is explicit absence; an omitted optional property reads as undefined. Values such as 0, false, and "" are still available values.

Inspect nullable and optional reads

Read types include absence

The optional-property marker affects the type you get when you read the property. It does not mean that every product object contains that property.

Use the existing product contracts and fixtures below. Hover a variable in your editor to inspect its inferred union type.

Product reads under strict checking

TypeScript infers a union for each optional-property read.

typescript
type ProductId = string | number;

type Product = {
  id: ProductId;
  name: string;
  stock?: number;
  featured?: boolean;
  subtitle?: string | null;
};

const notebook: Product = {
  id: 101,
  name: "Notebook",
  stock: 0,
  featured: false,
  subtitle: ""
};

const pen: Product = { id: "p-102", name: "Pen" };
const selectedProduct: Product | null = null;

const stock = pen.stock; // number | undefined
const subtitle = pen.subtitle; // string | null | undefined

What each read can produce

These types describe the possible result of reading each value, not just the values in one fixture.

Optional reads retain the possibility that the property was omitted.
ExpressionInferred typeWhy
selectedProductProduct | nullA selection can be explicitly absent.
pen.stocknumber | undefinedstock is optional and omitted on pen.
pen.subtitlestring | null | undefinedsubtitle is optional and may explicitly be null.

Narrow to the available string

Remove missing alternatives first

Guard each missing alternative explicitly. After a branch returns, control flow knows that alternative cannot reach the code below it.

Do not use typeof subtitle === "object" to establish that a subtitle is available: typeof null is also "object", and undefined is a separate possibility. Once explicit null and undefined guards have returned, the remaining type is string.

Narrow before a string operation

Each early return removes one missing alternative from the remaining path.

typescript
function formatSubtitle(subtitle: string | null | undefined): string {
  if (subtitle === null) {
    return "No subtitle";
  }

  if (subtitle === undefined) {
    return "No subtitle";
  }

  return subtitle.toUpperCase();
}

Tip

Keep available values available

An empty string reaches toUpperCase() safely because it is a string. A guard for absence must not discard it merely because it has no characters.

Practice: guard the subtitle

Complete the subtitle guards

Complete the two explicit guards so the final string operation is safe.

typescript
function formatSubtitle(subtitle: string | null | undefined): string {
  if (subtitle === ) {
    return "No subtitle";
  }

  if (subtitle === ) {
    return "No subtitle";
  }

  return subtitle.toUpperCase();
}

Step 4 of 8

Choose Defaults Without Losing Valid Values

Use optional chaining and nullish coalescing to handle an absent product selection without replacing valid falsy product values.

Handle an absent selection

Stop at an absent receiver

selectedProduct may be a Product or null. Optional chaining, written as ?., checks its receiver before continuing a property access.

  • If selectedProduct is null or undefined, the access stops and produces undefined.
  • If it is a product, TypeScript reads subtitle normally. That property can still be a string, null, or undefined.

Optional chaining handles an absent receiver; it does not make fields from a different object type available.

Diagram showing optional chaining reading a subtitle from an available product, stopping when the product is absent, and leaving an existing subtitle value unchanged.

The chain stops only when its receiver is nullish. A subtitle that belongs to an available product keeps its own value.

Optional chaining in the product search

The result can still contain the property’s own missing alternatives.

typescript
type ProductId = string | number;

type Product = {
  id: ProductId;
  name: string;
  stock?: number;
  featured?: boolean;
  subtitle?: string | null;
};

const selectedProduct: Product | null = {
  id: 101,
  name: "Notebook",
  stock: 0,
  featured: false,
  subtitle: ""
};

const subtitle = selectedProduct?.subtitle;
// subtitle: string | null | undefined

Preserve intentional falsy values

Default only nullish values

An optional-chain result may need a fallback. Use nullish coalescing, ??, when the fallback should apply only to null or undefined.

This is different from logical OR, ||. OR treats every falsy value as a reason to use the fallback, including values that may be intentional in a product interface.

Nullish coalescing versus logical OR

Compare the same fallback rule with values that are falsy or nullish.

`??` preserves available falsy values; `||` does not.
Stored valuevalue ?? "Default"value || "Default"
00"Default"
falsefalse"Default"
"" (empty string)"" (empty string)"Default"
null"Default""Default"
undefined"Default""Default"

Product display defaults

Here, the main fixture keeps its intentional empty subtitle, zero stock, and false featured value. A fallback is used only when the selection or property is nullish.

typescript
const subtitleLabel = selectedProduct?.subtitle ?? "No subtitle";
const stockLabel = selectedProduct?.stock ?? 0;
const featuredLabel = selectedProduct?.featured ?? false;

Practice the two-part expression

Build a safe subtitle label

Complete the product-display expression.

typescript
const subtitleLabel = selectedProduct.subtitle  "No subtitle";

Apply the fallback rule

Final check: preserve what is already valid

Use the main Notebook fixture: its stock is 0, its featured value is false, and its subtitle is an empty string. selectedProduct can also be absent.

  1. With the fallback values shown, list the results for these five inputs in order: 0 with 10; false with true; empty string with "Fallback"; null with "Fallback"; undefined with "Fallback".
  2. Write two TypeScript assignments: one display value for stock with fallback 10, and one for subtitle with fallback "No subtitle". Both must safely handle an absent selection and preserve the fixture’s intentional values.

Write at least 80 characters (0/80).

Step 5 of 8

Distinguish Object Shapes with in

Narrow a typed object union by checking a required property key, including when that property's value is an empty string.

Use a key that separates the shapes

Choose by required property

SearchTarget can describe one of two object shapes:

  • a text search with a required query property
  • a product lookup with a required productId property

Before a check, TypeScript cannot assume that either property exists. The in operator checks whether an object has a property key. When you check "query" in target, TypeScript narrows the matching branch to the text-search shape.

This is a property-presence check, not a truthiness check. An empty string is still a present query property.

Property presence narrows the shape

Diagram showing an object with a query property, even when its value is empty, going to a text-search branch; an object with productId goes to a product-lookup branch.

The key is present even when its string value is empty.

Implement the property-presence guard

Try the narrowed branches

For a focused local check, temporarily use this complete example in src/main.ts. Run npx tsc --noEmit, then run npm run dev and inspect the browser console.

src/main.ts

A required query key makes the first branch safe: it can read target.query.length. The other branch is narrowed to the shape that has productId.

typescript
type ProductId = string | number;

function formatProductId(id: ProductId): string {
  if (typeof id === "string") {
    return id.toUpperCase();
  }

  return id.toString();
}

type SearchTarget =
  | { query: string }
  | { productId: ProductId };

function getSearchTargetLabel(target: SearchTarget): string {
  if ("query" in target) {
    return `Text search (${target.query.length} characters)`;
  }

  return `Product lookup: ${formatProductId(target.productId)}`;
}

console.log(getSearchTargetLabel({ query: "" }));
console.log(getSearchTargetLabel({ productId: "p-102" }));
Expected output
Text search (0 characters)
Product lookup: P-102

Open the browser console after starting Vite. The first line confirms that an empty query still takes the text-search branch.

Tip

Use `in` for this specific uncertainty

This narrowing works because query is required in one alternative and absent from the other. A key that is optional, or appears in both alternatives, does not necessarily separate the shapes.

Also, in selects among alternatives that are already typed as SearchTarget. It does not validate arbitrary external data or prove that a property's value has the expected type.

Practice safe shape selection

Choose the guard

Complete the condition. A text-search object may carry an empty string, so the first branch must be selected by its key rather than its value.

typescript
function getTargetKind(target: SearchTarget): string {
  if ("query"  target) {
    return `text:${target.query.length}`;
  }

  return `product:${formatProductId(target.productId)}`;
}

Step 6 of 8

Check DOM Presence and Element Type

Safely narrow DOM selections by checking both whether an element exists and whether it has the required input subtype.

Separate absence from subtype

One lookup, two uncertainties

document.querySelector("#product-query") can either find an element or find nothing, so TypeScript gives its result the type Element | null. The selector ID identifies what you looked for; it does not prove which HTML element was found.

A null guard proves that a selection exists. A second guard, instanceof HTMLInputElement, proves that the existing element is specifically an input. Only then is the input-only value property safe to read.

Two runtime checks for the search field

A flow diagram showing a DOM selection checked first for absence and then for whether it is an input before its value is read.

First establish presence; then establish the element subtype required by the property you need.

Tip

Keep each guard proportional

Element is a broad DOM type. It supports shared properties such as textContent, but not the input-specific value property. Therefore, a status element needs only an absence check before updating textContent.

Prepare the local interface

index.html

Replace the relevant part of your existing index.html with this small search interface. Keep the status element in every test variation.

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Product search</title>
  </head>
  <body>
    <label for="product-query">Search products</label>
    <input id="product-query" value="desk" />
    <p id="search-status" role="status"></p>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Use early returns for distinct outcomes

The guards are deliberately separate. If the search input is absent, the code can show one message. If an element exists at that ID but is not an input, it can show a different message.

Select both references first. Guard statusElement before assigning its textContent; it does not need an input subtype check. Then guard queryElement for absence and subtype before reading its value.

Build the safe selection path

Order the guard chain

Put the operations in the only safe order for reading the search input value.

  1. Read queryElement.value.
  2. Return after handling queryElement === null.
  3. Select #product-query into queryElement.
  4. Return after handling a found element that is not an HTMLInputElement.

Complete the guarded DOM access

Complete src/main.ts. The function should distinguish an absent selection from a found non-input element, then read the search value only in the remaining branch.

typescript
function showQueryStatus(): void {
  const queryElement = document.();
  const statusElement = document.querySelector("#search-status");

  if (statusElement === null) {
    return;
  }

  if (queryElement === ) {
    statusElement.textContent = "Search input is missing.";
    return;
  }

  if (!(queryElement  HTMLInputElement)) {
    statusElement.textContent = "Search target is not an input.";
    return;
  }

  const query = queryElement.;
  statusElement.textContent = `Query ready: ${query}`;
}

showQueryStatus();

Test matching, missing, and non-input selections

Verify three DOM outcomes

Enter your completed code in src/main.ts, then run npx tsc --noEmit and npm run dev.

Test three versions of index.html while keeping #search-status:

  1. Leave the provided input in place.
  2. Remove the element with id="product-query".
  3. Replace that input with <div id="product-query"></div>.

For each version, inspect the status region in the browser. The second and third cases should be distinguishable rather than treated as the same failure.

Report your local results

Report the status text for all three HTML versions. For the two non-ready versions, identify whether the absence guard or the subtype guard produced that result.

Write at least 90 characters (0/90).

Step 7 of 8

Model Interface Outcomes with a Discriminant

Model product-search outcomes as a discriminated union and safely use the payload available in each status branch.

Represent outcomes as distinct shapes

One state, one shape

A discriminated union is an object union in which every variant shares a discriminant property with a distinct literal value. Here, status identifies the current search outcome.

Each object represents one coherent state. A loading or empty search has no payload; a successful search owns products; a failed search owns message. This is safer than one broad object with several optional payload properties, where contradictory combinations could be possible.

Describe the possible search states

Add this type alongside the existing Product type in src/main.ts.

typescript
type SearchState =
  | { status: "loading" }
  | { status: "empty" }
  | { status: "success"; products: Product[] }
  | { status: "error"; message: string };

SearchState variants

Diagram of four distinct product-search state shapes: loading, empty, success with products, and error with a message.

The status discriminant selects one complete object shape, not a mixture of payloads.

Let the union reject incomplete states

Check a state construction

Which proposed value will TypeScript reject when it is assigned to SearchState?

Narrow the complete object with status

Choose empty deliberately, then narrow

A Product[] may contain zero items, even in the success variant. Choose the explicit empty outcome after checking products.length.

When status is checked, TypeScript narrows the whole state object in that branch. Therefore, products is available only in the success branch and message only in the error branch.

Derive a message from the narrowed state

Use the state creator and message function in src/main.ts.

typescript
function createSearchState(products: Product[]): SearchState {
  if (products.length === 0) {
    return { status: "empty" };
  }

  return { status: "success", products };
}

function getSearchMessage(state: SearchState): string {
  switch (state.status) {
    case "loading":
      return "Searching products...";
    case "empty":
      return "No products found.";
    case "success":
      return `${state.products.length} products found.`;
    case "error":
      return `Search failed: ${state.message}`;
  }
}

Tip

Local check

Run npx tsc --noEmit after adding the code. The compiler accepts each payload access because the preceding case narrows state to its matching variant.

Apply discriminated-union narrowing

Complete the safe state message function

Complete the field accesses so this function reads each payload only in its matching narrowed branch.

typescript
function getSearchMessage(state: SearchState): string {
  switch (state.status) {
    case "loading":
      return "Searching products...";
    case "empty":
      return "No products found.";
    case "success":
      return `${state..length} products found.`;
    case "error":
      return `Search failed: ${state.}`;
  }
}

Step 8 of 8

Apply and Verify Safe Narrowing

Complete and locally verify a compact product-search interface that safely narrows unions, nullish values, DOM selections, object shapes, and interface states.

Connect uncertainties to guards

One interface, targeted guards

This final demo combines the checks you have already practiced. Each specialized operation follows a guard that answers one precise uncertainty:

  • typeof selects the current primitive alternative.
  • === null handles an absent selection or DOM lookup.
  • "query" in target selects an object shape by a required key.
  • instanceof HTMLInputElement establishes an input subtype.
  • state.status === ... selects one interface outcome.
  • ?? supplies a default only when a value is nullish.

The goal is not new syntax. It is to connect each uncertainty to the guard that makes the next operation safe.

Choose a guard for the uncertainty

Diagram linking a product-search interface to primitive type, absence, object shape, DOM subtype, and interface-state checks.

Narrowing removes only the alternative that prevents the next operation.

Guard selection map

Use this map while reading and testing the supplied demo.

Each guard addresses a different kind of uncertainty.
UncertaintyGuardSafe next operation
`ProductId``typeof`String or number formatting
`Product | null``=== null` or `?.`Use a selected product
`SearchTarget``"query" in target`Read the matching field
`Element | null`Null check + `instanceof`Read input `value`
`SearchState``status === ...`Read variant payloads

Supply the interface and shared code

1. Create the interface

In your existing local Vite project, replace index.html with this small interface. It supplies the search input and the status region used by the TypeScript demo.

index.html

index.html

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Product search</title>
  </head>
  <body>
    <main>
      <label for="product-query">Search products</label>
      <input id="product-query" type="search" />
      <p id="search-status" role="status"></p>
    </main>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

2. Add contracts and fixtures

Replace src/main.ts with this first section. chooseSelectedProduct has an explicit Product | null return type, so changing its argument later keeps both alternatives available to TypeScript.

Types, fixtures, and current values

src/main.ts — part 1

typescript
type ProductId = string | number;
type ViewMode = "grid" | "list";

type Product = {
  id: ProductId;
  name: string;
  stock?: number;
  featured?: boolean;
  subtitle?: string | null;
};

type SearchTarget = { query: string } | { productId: ProductId };

type SearchState =
  | { status: "loading" }
  | { status: "empty" }
  | { status: "success"; products: Product[] }
  | { status: "error"; message: string };

const viewMode: ViewMode = "grid";
const productFixture: Product = {
  id: 101,
  name: "Notebook",
  stock: 0,
  featured: false,
  subtitle: ""
};
const secondProductFixture: Product = { id: "p-102", name: "Pen" };

function chooseSelectedProduct(hasSelection: boolean): Product | null {
  if (hasSelection) {
    return productFixture;
  }

  return null;
}

const selectedProduct: Product | null = chooseSelectedProduct(true);
const searchTarget: SearchTarget = { productId: secondProductFixture.id };
const state: SearchState = {
  status: "success",
  products: [productFixture, secondProductFixture]
};

src/main.ts — part 2

Append this directly after part 1.

typescript
function formatProductId(id: ProductId): string {
  if (typeof id === "string") {
    return `SKU ${id.toUpperCase()}`;
  }

  return `Product #${id.toFixed(0)}`;
}

function describeTarget(target: SearchTarget): string {
  if ("query" in target) {
    return `Query: ${target.query}`;
  }

  return `Product ID: ${formatProductId(target.productId)}`;
}

function getSearchMessage(state: SearchState): string {
  switch (state.status) {
    case "loading":
      return "Searching products...";
    case "empty":
      return "No products found.";
    case "success":
      return `${state.products.length} products found.`;
    case "error":
      return `Search failed: ${state.message}`;
  }
}

Run and inspect the supplied demo

3. Render only after narrowing

Append this final section in src/main.ts. It checks the status element, distinguishes a missing query selection from a wrong element subtype, and preserves the fixture's intentional 0, false, and empty string with ??.

Complete runnable demo

src/main.ts — part 3

typescript
const queryElement = document.querySelector("#product-query");
const statusElement = document.querySelector("#search-status");

function renderSearchStatus(
  selectedProduct: Product | null,
  state: SearchState
): void {
  if (statusElement === null) {
    return;
  }

  if (queryElement === null) {
    statusElement.textContent = "Search input is missing.";
    return;
  }

  if (!(queryElement instanceof HTMLInputElement)) {
    statusElement.textContent = "Search target is not an input.";
    return;
  }

  const stock = selectedProduct?.stock ?? "Unavailable";
  const featured = selectedProduct?.featured ?? false;
  const subtitle = selectedProduct?.subtitle ?? "No subtitle";
  const selectedName = selectedProduct?.name ?? "No selected product";
  const selectedId = selectedProduct === null
    ? "No product ID"
    : formatProductId(selectedProduct.id);

  queryElement.value = describeTarget(searchTarget);
  statusElement.textContent = `${getSearchMessage(state)} Mode: ${viewMode}. Selected: ${selectedName}; ID: ${selectedId}; stock: ${stock}; featured: ${featured}; subtitle: "${subtitle}"`;
}

renderSearchStatus(selectedProduct, state);
Expected output
Input value:
Product ID: SKU P-102

Status region:
2 products found. Mode: grid. Selected: Notebook; ID: Product #101; stock: 0; featured: false; subtitle: ""

This is the expected browser result, not terminal output. A successful `npx tsc --noEmit` command is normally silent.

Expected initial browser view

A product-search page with a populated search field and a successful status region that visually preserves zero, false, and an empty subtitle.

Compare exact initial strings with the code output; the image shows the layout and preserved-value result.

4. Type-check and preview

From the project folder, run the type check and then start the local preview. Open the local address printed by Vite.

Terminal commands

Run these commands from the project folder.

bash
npx tsc --noEmit
npm run dev
Expected output
> npm run dev
> vite

  VITE v6.0.0  ready in 180 ms

  ➜  Local:   http://localhost:5173/
  ➜  press h + enter to show help

The Vite version, startup time, and local port can differ. Type checking succeeds without output when there are no diagnostics.

Apply the guards in a valid function

Rebuild the key checks

Complete this self-contained function so each access is safe and the stock default preserves an intentional zero.

typescript
function getCompactStatus(
  queryElement: Element | null,
  selectedProduct: Product | null,
  state: SearchState
): string {
  if (queryElement  null) {
    return "Search input is missing.";
  }

  if (!(queryElement  HTMLInputElement)) {
    return "Search target is not an input.";
  }

  const stock = selectedProduct?.stock  "Unavailable";

  if (state.status  "error") {
    return `Search failed: ${state.message}; stock: ${stock}`;
  }

  return `Query: ${queryElement.value}; ${getSearchMessage(state)}; stock: ${stock}`;
}

Verify and close

Verify every important branch

Use your local copy for these controlled variations. After each source edit, run npx tsc --noEmit, refresh the browser, observe the result, and restore the initial code before the next variation.

  1. Keep the initial success state to confirm both identifier formats and the preserved 0, false, and empty subtitle.
  2. Replace the state value one at a time with { status: "loading" }, { status: "empty" }, and { status: "error", message: "Network unavailable" }.
  3. Change chooseSelectedProduct(true) to chooseSelectedProduct(false). Its declared return type remains Product | null, so this variation must still type-check.
  4. Change the query selector to "#missing-query", then to "#search-status". Finally change the status selector to "#missing-status". The final case has no status region to update, but it should return safely without an exception.

Summary

Safe narrowing review

  • A guard narrows the alternatives that remain reachable before a specialized operation.
  • Nullish coalescing preserves valid 0, false, and empty-string values.
  • A DOM selector needs a presence check, and input-only properties need a subtype check.
  • A discriminant makes only the active SearchState payload available.

Report the integrated verification

Report your local verification in a short paragraph or bulleted list. Include the initial input and status result; all four SearchState messages; the result after the selected-product variation; the result for missing and wrong-type query selections; what occurred when the status selection was missing; and whether every variation passed npx tsc --noEmit. Mention that you restored the initial source afterward.

Write at least 260 characters (0/260).

Tutorial completed

Congratulations! You completed: Narrow Unions and Nullable Values

You completed a product-search demo by narrowing each uncertainty before using a specialized value.

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