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
Enter fullscreen mode Exit fullscreen mode
That naturally leads to an API like:
const result = await modal.open(renameReportModal, input);
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Now all of these expressions compile:
result.name;
result.nmae;
result.whatever;
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
Start with Modal<TInput, TResult>
Suppose a rename modal receives:
interface RenameReportInput {
reportId: string;
currentName: string;
}
Enter fullscreen mode Exit fullscreen mode
and can produce one of two domain outcomes:
type RenameReportResult =
| {
status: "renamed";
name: string;
}
| {
status: "cancelled";
};
Enter fullscreen mode Exit fullscreen mode
We can describe the modal conceptually as:
Modal<RenameReportInput, RenameReportResult>
Enter fullscreen mode Exit fullscreen mode
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>
);
}
Enter fullscreen mode Exit fullscreen mode
Inside the component:
input.currentName;
Enter fullscreen mode Exit fullscreen mode
is known to be a string.
And close() only accepts a valid RenameReportResult. This is rejected:
close({
status: "renamed",
value: name,
});
Enter fullscreen mode Exit fullscreen mode
because value is not part of the contract. This is also rejected:
close({
status: "deleted",
});
Enter fullscreen mode Exit fullscreen mode
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,
});
Enter fullscreen mode Exit fullscreen mode
The important part is not the syntax.
It is that the resulting value carries both generic types.
Conceptually:
typeof renameReportModal
// ModalDefinition<RenameReportInput, RenameReportResult>
Enter fullscreen mode Exit fullscreen mode
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,
});
Enter fullscreen mode Exit fullscreen mode
allows TypeScript to validate the input:
await modal.open(renameReportModal, {
reportId: 123,
currentName: report.name,
});
Enter fullscreen mode Exit fullscreen mode
The invalid numeric reportId is detected at compile time.
The returned value is also inferred:
// RenameReportResult
const result = await modal.open(...);
Enter fullscreen mode Exit fullscreen mode
So this is valid:
if (result.status === "renamed") {
console.log(result.name);
}
Enter fullscreen mode Exit fullscreen mode
while this is not:
console.log(result.name);
Enter fullscreen mode Exit fullscreen mode
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;
};
Enter fullscreen mode Exit fullscreen mode
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);
}
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode
to:
{
status: "renamed";
nextName: string;
}
Enter fullscreen mode Exit fullscreen mode
With an end-to-end typed contract, existing consumers such as:
result.name;
Enter fullscreen mode Exit fullscreen mode
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,
});
Enter fullscreen mode Exit fullscreen mode
and then:
await modal.open(infoModal);
Enter fullscreen mode Exit fullscreen mode
The API can distinguish between:
Modal<void, Result>
Enter fullscreen mode Exit fullscreen mode
and:
Modal<Input, Result>
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode
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,
}),
});
Enter fullscreen mode Exit fullscreen mode
Now:
const result = await modals.open("renameReport", {
reportId: "42",
currentName: "Architecture notes",
});
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
must accept only the input described by the modal.
2. Component props
ModalComponentProps<TInput, TResult>
Enter fullscreen mode Exit fullscreen mode
must expose the same input type to the component.
3. Completion
close(result)
Enter fullscreen mode Exit fullscreen mode
must accept only the declared result.
4. Consumer
await modal.open(...)
Enter fullscreen mode Exit fullscreen mode
must resolve to the same result type.
Conceptually:
Modal<TInput, TResult>
/
/
TInput TResult
↓ ↑
modal component close(result)
↓ ↑
open(...) → Promise<TResult>
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode
instead of:
Promise<any>
Enter fullscreen mode Exit fullscreen mode
That prevents unchecked property access.
But it pushes the responsibility back onto the consumer:
const result = await show(...);
if (isRenameResult(result)) {
// ...
}
Enter fullscreen mode Exit fullscreen mode
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",
});
Enter fullscreen mode Exit fullscreen mode
That is a valid TResult. But an external dismissal can represent something different:
handle.dismiss();
Enter fullscreen mode Exit fullscreen mode
In react-modal-manager, dismissal rejects the pending operation with ModalDismissError. That means the API can distinguish:
modal completed with a valid TResult
Enter fullscreen mode Exit fullscreen mode
from:
modal lifecycle was interrupted/dismissed
Enter fullscreen mode Exit fullscreen mode
The type contract describes successful completion. Lifecycle semantics describe how the operation can terminate outside that completion path.
That distinction deserves its own discussion, and I will cover it 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>
Enter fullscreen mode Exit fullscreen mode
but something closer to:
open<Input, Result>(
modal: Modal<Input, Result>,
input: Input,
): Promise<Result>
Enter fullscreen mode Exit fullscreen mode
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! 👇