keepcalculator

06 Numbers and Powers

Type Safe Modal API Design: Beyond Promise

Card No. 06·23836 · Updated · Standard formula, shown below

Average pace 5.00 min/km
Average speed
12.00 km/h
Result
Finish time
50 min
You entered
Distance
10 km
You entered
You enter
Finish time, Distance
You get
Average pace, Average speed
Rounding
2 dp

Designing a type-safe modal API means moving beyond a simple Promise-based open() function to a system where the compiler verifies every payload, result, and dismissal reason before your code runs. You get this by modeling each modal as a discriminated union on its props and result types, then using a generic show<T> function that returns a typed promise. This article explains the patterns, the trade-offs, and how to implement one in TypeScript.

Formula

The type-safe modal API is not a mathematical formula but a type-level contract. The core is a generic function:

show<T extends ModalDefinition>(modal: T): Promise<T['result']>

Where ModalDefinition is a discriminated union:

type ModalDefinition =
  | { kind: 'confirm'; props: { message: string }; result: boolean }
  | { kind: 'prompt'; props: { label: string }; result: string | null }
  | { kind: 'datePicker'; props: { initial: Date }; result: Date | null };

The kind field is the discriminant. The props are what the caller passes in, and result is what the modal resolves to. The show function uses a generic constraint so that show({ kind: 'confirm', props: { message: 'Delete?' } }) returns Promise<boolean>, while show({ kind: 'prompt', props: { label: 'Name' } }) returns Promise<string | null>. The compiler rejects any mismatch—wrong props, wrong result handling—at compile time.

Worked Example

Using the default values from the calculator block (which is unrelated but demonstrates a typed arithmetic function):

  1. Input: finish time = 50 minutes, distance = 10 km.
  2. Pace = 50 / 10 = 5.0 min/km.
  3. Speed = 10 / (50 / 60) = 12.0 km/h.

The calculator returns pace 5.00 min/km and speed 12.00 km/h. For the modal API, a worked example would be: calling show({ kind: 'confirm', props: { message: 'Delete?' } }) returns a promise that resolves to true if the user clicks OK, false otherwise. The type system guarantees you handle a boolean, not a string or undefined.

Why Promise-Based Modals Fall Short

A naive implementation often looks like this:

function openModal(name: string, props: any): Promise<any>

This works but loses all type safety. The caller can pass the wrong props, and the result is any, so errors surface at runtime, not compile time. Common problems:

  • Misspelled prop names are not caught.
  • Result types are inconsistent across modals.
  • Dismissal (ESC key, backdrop click) is often untyped—resolves to undefined or throws.
  • Refactoring a modal’s props silently breaks every caller.

The promise pattern also encourages a single generic open that erases type information. The solution is to make the modal definition itself carry its types.

Designing the Type-Safe API

Start by defining a registry of all modals in your app:

interface ModalMap {
  confirm: { props: { message: string }; result: boolean };
  prompt: { props: { label: string }; result: string | null };
  datePicker: { props: { initial: Date }; result: Date | null };
}

Then the generic show function:

function show<K extends keyof ModalMap>(
  kind: K,
  props: ModalMap[K]['props']
): Promise<ModalMap[K]['result']> {
  // implementation renders the modal and resolves the promise
}

Callers now write:

const ok = await show('confirm', { message: 'Delete?' });
// ok: boolean
const name = await show('prompt', { label: 'Name' });
// name: string | null

If you pass { message: 123 }, the compiler errors. If you try to use name.toUpperCase() without checking for null, the compiler errors. This is the core of type safety—errors appear in your editor, not in production.

Handling Dismissal and Cancellation

One subtlety is how to represent the user closing the modal without a decision. Three common approaches:

  • Nullable result: resolve with null on dismissal. Simple, but forces every caller to check for null.
  • Discriminated result: return { status: 'confirmed', value: T } | { status: 'dismissed' }. More verbose but explicit.
  • Throw on dismiss: reject the promise. This is often a mistake because it conflates user cancellation with an error, and unhandled rejections can crash tests.

The discriminated result is the most type-safe because it forces the caller to handle both branches. For example:

type ModalResult<T> = { status: 'confirmed'; value: T } | { status: 'dismissed' };

async function show<K extends keyof ModalMap>(...): Promise<ModalResult<ModalMap[K]['result']>>

Now the caller must check result.status before using result.value. This eliminates a whole class of “undefined is not a function” bugs.

Implementation Strategies

You can implement the show function in several ways:

  • Central registry: keep a map from kind to a React/Vue component. The show function looks up the component, renders it in a portal, and wires up the promise.
  • Context-based: a modal provider exposes show via context, so any component can call it without prop drilling.
  • External store: a global store (like Zustand or Redux) holds the current modal stack, and show dispatches an action. The promise is resolved by a listener.

Each has trade-offs. A central registry is easiest to type because the map is explicit. Context is convenient but can be harder to test. An external store scales well for complex apps but adds boilerplate. The key is that the type definition lives in one place—the ModalMap—and the implementation just follows it.

Common Mistakes and How to Avoid Them

  • Using any in the map: defeats the purpose. Always specify concrete types.
  • Forgetting to handle dismissal: always include a dismissal path in the result type.
  • Overloading show with unions: a single show that takes a union of all modals is harder to use than a generic keyed version.
  • Not testing the type contract: use tsd or expect-type to assert that show('confirm', ...) returns Promise<ModalResult<boolean>>.
  • Mixing synchronous and async flows: if a modal can be opened without a user gesture, the promise may never resolve. Add a timeout or a way to force-close.

Comparing to Other Approaches

Alternatives to a promise-based API include:

  • Callback props: <ConfirmModal onConfirm={...} onCancel={...} />. This is type-safe but leads to nested callbacks and is harder to compose.
  • State machine: a reducer that tracks modal open/close. Type-safe but verbose and requires more boilerplate.
  • Render props: pass a function that receives the confirm action. Works but can be clunky.

The promise-based generic API is often the best balance of type safety and ergonomics, especially for one-off modals. It also composes well with async/await, making sequential flows (confirm → prompt → result) readable.

Frequently Asked Questions

How do I type a modal that returns different values based on user input?

Use a discriminated union on the result. For example, a modal that asks “Save changes?” could return { status: 'save' } | { status: 'discard' } | { status: 'cancel' }. The caller then switches on the status. This is more explicit than a boolean and prevents misinterpretation.

Should I reject the promise when the user presses ESC?

Generally no. Rejecting should be reserved for actual errors (like a crash in the modal component). User dismissal is a normal outcome, so resolve with a dismissal marker. Throwing on ESC makes it hard to distinguish cancellation from failure, and unhandled rejections can trigger error boundaries or test failures.

How do I handle modals that need to be opened from outside React (e.g., in a utility function)?

Use a global store or a singleton modal manager that is not tied to the component tree. The show function can dispatch an action to the store, and a React provider subscribes to it and renders the modal. The promise is resolved by the provider when the modal closes. This keeps the API usable anywhere while retaining type safety.

Can I make the API work with multiple modals open at once?

Yes. The show function can push a new entry onto a stack. Each entry has its own promise resolver. The type map remains the same; you just need to manage the stack order. Be careful with z-index and focus management, but the type contract doesn’t change.

What about accessibility (focus trap, aria) in a type-safe API?

Type safety doesn’t cover accessibility. You still need to implement focus trapping, ARIA attributes, and keyboard handling in the modal component. The type system only ensures data correctness. Consider wrapping your modal component in a well-tested accessible base, and keep the type map separate from the visual implementation.

Is it worth the extra complexity for a small app?

If you have more than two or three modals, the type-safe API pays off because it prevents prop mismatches and result handling bugs. For a single modal, a simple component with props is fine. The pattern scales well, so adopting it early avoids a painful refactor later.

Related

Formula used
in t | Finish time | min | 50 | 0
in d | Distance | km | 10 | 0 | 100000
out pace | Average pace | min/km | t / d | 2
out speed | Average speed | km/h | d / (t / 60) | 2

Each line is one input (in, sel, list) or one result (out: label, unit, formula, decimals). How the calculators work

Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.

More cards in this drawer

The whole Numbers and Powers drawer →