Skip to content

feat(prompts): add onCancel callback option to all prompts#544

Open
aqeelat wants to merge 1 commit into
bombshell-dev:mainfrom
aqeelat:on-cancel
Open

feat(prompts): add onCancel callback option to all prompts#544
aqeelat wants to merge 1 commit into
bombshell-dev:mainfrom
aqeelat:on-cancel

Conversation

@aqeelat
Copy link
Copy Markdown

@aqeelat aqeelat commented May 26, 2026

Summary

Adds an optional onCancel callback to all prompt functions via CommonOptions. When the user cancels a prompt (Ctrl+C or Escape), the callback is invoked — eliminating the need for isCancel guards at every call site. When onCancel is provided, the return type narrows from Promise<T | symbol> to Promise<T>.

Closes #83

Motivation

Every clack user repeats this pattern:

const result = await confirm({ message: "Continue?" });
if (isCancel(result)) {
  cancel("Operation cancelled.");
  process.exit(0);
}

This PR makes it:

const result = await confirm({
  message: "Continue?",
  onCancel: () => {
    cancel("Operation cancelled.");
    process.exit(0);
  },
});
// result is `boolean` — no isCancel guard needed

Changes

  • CommonOptions — added onCancel?: () => void
  • handleCancel utility — exported from @clack/prompts for custom prompt implementations
  • All 12 prompt functions — converted to function declarations with overloads for return type narrowing
  • Tests — runtime tests for cancel behavior + compile-time type narrowing tests

Type narrowing

Each prompt uses function overloads so TypeScript narrows the return type when onCancel is present:

// Without onCancel: result is boolean | symbol
const a = await confirm({ message: "Continue?" });
//    ^? boolean | symbol

// With onCancel: result is boolean (narrowed)
const b = await confirm({ message: "Continue?", onCancel: () => { process.exit(0); } });
//    ^? boolean

This works for all prompts: text, confirm, select, multiselect, groupMultiselect, password, autocomplete, autocompleteMultiselect, selectKey, date, multiline, path.

Design decisions

  • Additive, no breaking changesonCancel is optional; existing code works unchanged
  • Function overloads for narrowing — the idiomatic TypeScript pattern; converts export const arrows to export function declarations
  • handleCancel is exported — useful for custom prompt implementations

Test plan

  • All 727 existing + new tests pass
  • pnpm run build succeeds
  • pnpm run types succeeds
  • pnpm run lint succeeds
  • pnpm run format succeeds
  • Type narrowing verified via compile-time assertions (@ts-expect-error)

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 26, 2026

🦋 Changeset detected

Latest commit: 5a5d7ac

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@clack/prompts Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@43081j
Copy link
Copy Markdown
Collaborator

43081j commented May 26, 2026

When onCancel is provided, the return type narrows from Promise<T | symbol> to Promise.

Unsafely, yes:

const result = await confirm({
  message: "Continue?",
  onCancel: () => {
    console.log("do anything here that _doesn't_ process.exit");
  },
});

// result is still T | Symbol at runtime

i'm not sure we should go down this path yet until the issue has a resolution.

my main concern is that this is mixing two different architectures:

  1. cancellations as symbols
  2. cancellations as callbacks

we currently use the first one. introducing the second means we now have two ways of dealing with cancellations, which seems awkward to me.

ultimately you're just trying to avoid having to type-check results, but that takes about as much code as your onCancel handler. could you explain what the gain is?

Add optional onCancel callback to CommonOptions, available on all 12
prompts. When the callback returns `never` (e.g. calls process.exit
or throws), the prompt's return type narrows to exclude the cancel
symbol via function overloads.

- Add handleCancel utility in common.ts
- Add function overloads with () => never for type-safe narrowing
- Convert prompts from const arrows to function declarations (required for overloads)
- Comprehensive runtime tests for all 12 prompts
- Update README and changeset
@aqeelat
Copy link
Copy Markdown
Author

aqeelat commented May 28, 2026

@43081j Thanks for the feedback! You were right that the previous version was unsound. I've updated the PR to address your concerns.

Type narrowing is now sound using () => never overloads:

export function confirm(opts: ConfirmOptions & { onCancel: () => never }): Promise<boolean>;
export function confirm(opts: ConfirmOptions): Promise<boolean | symbol>;

Narrowing only triggers when the callback's return type is never (e.g. process.exit(0) or throw). A callback like () => console.log("cancelled") returns void, so the return type stays T | symbol — no false narrowing. This is sound because TypeScript knows the callback never returns, making the cancel symbol unreachable.

Symbols remain the single source of truth. onCancel is just a convenience hook for the common pattern of log + exit. The callback fires before the symbol is returned; if it throws, the promise rejects and the symbol is never returned.

The gain isn't about saving lines — it's about the compiler enforcing that cancellation is handled. With isCancel, nothing prevents a caller from forgetting the guard, leading to symbol values leaking into business logic (only caught at runtime). With () => never, the compiler proves the cancel path is handled.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Request] Improve cancellation API

2 participants