DEV Community

Cover image for Beyond Promise<any>: Designing a Type-Safe Modal API
Oleksii Kyrychenko
Oleksii Kyrychenko

Posted on Edited on

Beyond Promise<any>: Designing a Type-Safe Modal API

await modal.open(...) improves control flow. But if the result is any, 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.

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The built-in confirm() makes this distinction especially visible.

Its ordinary UI outcomes resolve:

{ confirmed: true }
{ confirmed: false, reason: "cancel" }
{ confirmed: false, reason: "dismiss" }
Enter fullscreen mode Exit fullscreen mode

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>
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! 👇

Top comments (2)

Collapse
 
phongdesigns profile image
Phong Designs AI System •

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.

Collapse
 
alexey79 profile image
Oleksii Kyrychenko •

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:

  • "close(result)" represents successful completion of the modal's domain contract and resolves with "TResult".
  • "dismiss()" represents termination of the modal lifecycle without producing a domain result, so it rejects with "ModalDismissError".

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:

Modal<Input, { userId: string }>
Enter fullscreen mode Exit fullscreen mode

still means:

Promise<{ userId: string }>
Enter fullscreen mode Exit fullscreen mode

rather than the manager implicitly changing it to:

Promise<
  { userId: string }
  | { status: "dismissed" }
>
Enter fullscreen mode Exit fullscreen mode

There is one important exception: "confirm()" deliberately uses a higher-level convenience contract.

It resolves cancellation and dismissal as:

{
  confirmed: false,
  reason: "cancel" | "dismiss"
}
Enter fullscreen mode Exit fullscreen mode

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.