await modal.open(...)improves control flow. But if the result isany, the most important part of the contract is still unchecked.
In the previous article, I described a modal interaction as an asynchronous operation:
Input
↓
[ user interaction ]
↓
Result
That naturally leads to an API like:
const result = await modal.open(renameReportModal, input);
That immediately raises the question that determines whether the abstraction is actually safe:
What is the type of result?
If the answer is any, the control flow improved but the contract did not. This article explores the type model behind a modal API shaped as:
Modal<TInput, TResult>
A Promise is not a type contract
A modal manager can expose a promise API and still lose information:
const result = await showModal("rename-report", {
reportId: report.id,
});
// result: any
Now all of these expressions compile:
result.name;
result.nmae;
result.whatever;
The compiler cannot tell us whether:
- the modal really returns
name; - the field was renamed;
- the caller made a typo;
- this modal returns a completely different shape.
The promise itself is not the important part. The important part is preserving the relationship between:
modal definition
↓
input type
↓
result type
↓
call site
Start with Modal<TInput, TResult>
Suppose a rename modal receives:
interface RenameReportInput {
reportId: string;
currentName: string;
}
and can produce one of two domain outcomes:
type RenameReportResult =
| {
status: "renamed";
name: string;
}
| {
status: "cancelled";
};
We can describe the modal conceptually as:
Modal<RenameReportInput, RenameReportResult>
That generic relationship should survive through every layer of the API.
Type the producer: the modal component
In react-modal-manager, the component receives typed input and a typed close function:
import {
createModal,
type ModalComponentProps,
} from "@okyrychenko-dev/react-modal-manager";
interface RenameReportInput {
reportId: string;
currentName: string;
}
type RenameReportResult =
| { status: "renamed"; name: string }
| { status: "cancelled" };
function RenameReportModal({
input,
close,
}: ModalComponentProps<RenameReportInput, RenameReportResult>) {
const [name, setName] = useState(input.currentName);
return (
<dialog open>
<h2>Rename report</h2>
<input
value={name}
onChange={(event) => setName(event.target.value)}
/>
<button
onClick={() => close({ status: "cancelled" })}
>
Cancel
</button>
<button
onClick={() =>
close({
status: "renamed",
name,
})
}
>
Rename
</button>
</dialog>
);
}
Inside the component:
input.currentName;
is known to be a string. And close() only accepts a valid RenameReportResult. This is rejected:
close({
status: "renamed",
value: name,
});
because value is not part of the contract. This is also rejected:
close({
status: "deleted",
});
because "deleted" is not a possible status. The producer side of the interaction is checked.
Make the definition carry the contract
The next layer creates the modal definition:
export const renameReportModal =
createModal<RenameReportInput, RenameReportResult>({
component: RenameReportModal,
});
The important part is not the syntax. It is that the resulting value carries both generic types.
Conceptually:
typeof renameReportModal
// ModalDefinition<RenameReportInput, RenameReportResult>
Now the definition is enough information for the caller.
Infer the contract at the call site
Opening the modal:
const result = await modal.open(renameReportModal, {
reportId: report.id,
currentName: report.name,
});
allows TypeScript to validate the input:
await modal.open(renameReportModal, {
reportId: 123,
currentName: report.name,
});
The invalid numeric reportId is detected at compile time.
The returned value is also inferred:
// RenameReportResult
const result = await modal.open(...);
So this is valid:
if (result.status === "renamed") {
console.log(result.name);
}
while this is not:
console.log(result.name);
because name does not exist on the cancelled branch. That is exactly what we want. The result type should force the caller to acknowledge the states the modal can produce.
Use discriminated unions for meaningful outcomes
Modal results often have several meaningful outcomes.
For example:
type ConflictResolutionResult =
| {
status: "keep-local";
}
| {
status: "accept-remote";
}
| {
status: "merge";
mergedValue: string;
};
Now the caller can use exhaustive control flow:
switch (result.status) {
case "keep-local":
return keepLocal();
case "accept-remote":
return acceptRemote();
case "merge":
return saveMerged(result.mergedValue);
}
If another result variant is added later, an exhaustive check can make all incomplete call sites fail during development. This is much more useful than returning loosely structured objects from the modal.
Let refactors break at compile time
Suppose the result changes from:
{
status: "renamed";
name: string;
}
to:
{
status: "renamed";
nextName: string;
}
With an end-to-end typed contract, existing consumers such as:
result.name;
fail immediately.
That is exactly the kind of breakage I want the compiler to find. Without a typed result, the same refactor can become a runtime regression hidden in a rarely used workflow.
Inputless modals should still be representable
Not every modal needs input. An information dialog might be:
const infoModal = createModal<void, void>({
component: InfoModal,
});
and then:
await modal.open(infoModal);
The API can distinguish between:
Modal<void, Result>
and:
Modal<Input, Result>
so callers are not forced to pass meaningless placeholders such as undefined everywhere. Small details like this matter because type-safe APIs can still become unpleasant if their ergonomics are poor.
Preserve inference outside React too
Type safety becomes more interesting when the modal must be opened outside a React component.
A string-only API often looks like this:
open("rename-report", payload);
The problem is that the relationship between the string and the payload usually exists only by convention.
A typed registry can preserve it:
import {
createModal,
createModalRegistry,
} from "@okyrychenko-dev/react-modal-manager";
export const modals = createModalRegistry({
renameReport: createModal({
component: RenameReportModal,
}),
deleteReport: createModal({
component: DeleteReportModal,
}),
});
Now:
const result = await modals.open("renameReport", {
reportId: "42",
currentName: "Architecture notes",
});
can infer both the required input and the result from "renameReport". The key is not just a string anymore. Within the registry's type system, it identifies a particular input/result contract.
The invariant: never lose TInput or TResult
For this style of API, I want four things to be connected:
1. Input
modal.open(definition, input)
must accept only the input described by the modal.
2. Component props
ModalComponentProps<TInput, TResult>
must expose the same input type to the component.
3. Completion
close(result)
must accept only the declared result.
4. Consumer
await modal.open(...)
must resolve to the same result type.
Conceptually:
Modal<TInput, TResult>
/ \
/ \
TInput TResult
↓ ↑
modal component close(result)
↓ ↑
open(...) → Promise<TResult>
There should be no point in that chain where the type silently widens to any.
unknown is safer than any; inference is better when the definition already knows the type
A library can improve safety by returning:
Promise<unknown>
instead of:
Promise<any>
That prevents unchecked property access.
But it pushes the responsibility back onto the consumer:
const result = await show(...);
if (isRenameResult(result)) {
// ...
}
Sometimes that is appropriate. But if the library already knows which modal is being opened, requiring the caller to rediscover the result type is unnecessary.
The strongest API is the one where the definition itself carries the contract.
Type safety does not replace lifecycle semantics
There is one important boundary here. A domain result and a lifecycle dismissal are not necessarily the same thing. For example, the modal can explicitly resolve:
close({
status: "cancelled",
});
That is a valid TResult. But an external dismissal can represent something different:
handle.dismiss();
In react-modal-manager, dismissal rejects the pending operation with ModalDismissError. That means the API can distinguish:
modal completed with a valid TResult
from:
modal lifecycle was interrupted/dismissed
The type contract describes successful completion.
Lifecycle semantics describe how the operation can terminate outside that completion path.
There is one TypeScript detail worth making explicit here: Promise<TResult> only describes the resolved value. TypeScript does not encode a rejection type in Promise.
So this:
const result = await modal.open(renameModal, input);
tells us precisely what result is when the operation resolves, but it does not mean dismissal cannot reject.
If dismissal is possible, callers that own that branch should handle it explicitly:
try {
const result = await modal.open(renameModal, input);
if (result.status === "renamed") {
await renameReport({
id: input.reportId,
name: result.name,
});
}
} catch (error) {
if (error instanceof ModalDismissError) {
return;
}
throw error;
}
That is not a weakness of Modal<TInput, TResult>. It is a boundary between two different contracts:
TResult
= successful domain completion
rejection
= exceptional or lifecycle termination
The built-in confirm() makes this distinction especially visible.
Its ordinary UI outcomes resolve:
{ confirmed: true }
{ confirmed: false, reason: "cancel" }
{ confirmed: false, reason: "dismiss" }
For example, pressing Escape in the built-in confirmation UI is treated as a typed user outcome.
But programmatic lifecycle operations such as handle.dismiss(), closeAll(), or provider teardown reject with ModalDismissError.
The same word — "dismiss" — can therefore describe two different layers:
- a domain-level result produced intentionally by the confirmation component;
- a lifecycle-level interruption produced by the manager.
Keeping those layers explicit prevents the type model from pretending that every termination path is a TResult.
That distinction becomes central in the lifecycle article later in this series.
Why this matters in large codebases
The value of this design is not most visible in the first demo.
It becomes visible six months later.
When:
- a modal result evolves;
- a payload field is renamed;
- the same modal has twenty call sites;
- a workflow is moved into a service;
- another developer adds a new result variant;
- a refactor crosses feature boundaries.
The goal is not to make modal code look more sophisticated. The goal is to make invalid orchestration harder to express.
Final thoughts
A promise-shaped API gives modal flows sequential control flow. A typed promise-shaped API also preserves the contract that makes that control flow trustworthy.
The important abstraction is therefore not:
open(): Promise<any>
but something closer to:
open<Input, Result>(
modal: Modal<Input, Result>,
input: Input,
): Promise<Result>
with those types preserved through the component, completion function, registry, and caller.
That turns a modal from an opaque UI event into a checked application boundary.
The next article moves beyond typing:
Designing a Modal Manager Without a Global Singleton — how provider-owned state, typed imperative access, and isolated roots affect tests, SSR, Storybook, and micro-frontends.
If you like the approach, drop a ⭐️ on the GitHub repo and let me know what you think in the comments! 👇
Top comments (2)
The dismiss-rejects choice is the one I'd push on. From the user's side, pressing Escape and clicking Cancel are the same intent, but here they arrive through two different channels: one as a typed "cancelled" result, the other as a thrown ModalDismissError. The exhaustive switch only covers the first, so a call site that forgets its try/catch compiles fine and fails on the path that's easiest to trigger.
Folding dismissal into the union (status: "dismissed") would let the compiler catch it the same way it catches a new variant, and leave rejection for things that are actually errors.
That is a good observation. This distinction is intentional, though I agree it needs to be very explicit in the API documentation.
I separate two concepts:
The main reason I do not automatically add "{ status: "dismissed" }" to every result is that I want the modal definition to fully own its result type.
For example:
still means:
rather than the manager implicitly changing it to:
There is one important exception: "confirm()" deliberately uses a higher-level convenience contract.
It resolves cancellation and dismissal as:
because cancel/Escape are expected outcomes of a confirmation flow and should not require "try/catch".
So I see "TResult" exhaustiveness and lifecycle termination as two separate contracts rather than trying to encode both into every modal's result union.
Your point about TypeScript not having checked exceptions is absolutely valid. It means this distinction needs to be documented very clearly so callers do not accidentally treat "dismiss()" as another "TResult" branch.