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.
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>
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 (0)