DEV Community

Oleksii Kyrychenko
Oleksii Kyrychenko

Posted 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.

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

Top comments (0)