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):
- Input: finish time = 50 minutes, distance = 10 km.
- Pace = 50 / 10 = 5.0 min/km.
- 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
undefinedor 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
nullon dismissal. Simple, but forces every caller to check fornull. - 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
showfunction looks up the component, renders it in a portal, and wires up the promise. - Context-based: a modal provider exposes
showvia 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
showdispatches 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
anyin the map: defeats the purpose. Always specify concrete types. - Forgetting to handle dismissal: always include a dismissal path in the result type.
- Overloading
showwith unions: a singleshowthat takes a union of all modals is harder to use than a generic keyed version. - Not testing the type contract: use
tsdorexpect-typeto assert thatshow('confirm', ...)returnsPromise<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
- Delete Picture Kik? How to Remove Photos
- What Happens to X Replies, DMs, and Followers After Deletion?
- Data Breaches That Shook the World
- Disable Google Assistant: Turn It Off on Android & Home
- Resin vs FDM Printers for Miniatures: Detail, Durability, Cost
- Are Networked Implants Safe? What If One Fails?