[READ-ONLY] Mirror of https://github.com/bombshell-dev/clack. Effortlessly build beautiful command-line apps bomb.sh/docs/clack/basics/getting-started/
cli command-line command-line-app node prompt prompts
5

Configure Feed

Select the types of activity you want to include in your feed.

feat: add async validation

This adds the ability to have async validation to all prompts.

**For now, only the text prompt will render a validating message.**

Every other prompt needs its own individual follow-up PR to add support
for rendering the new `validating` state. They will all accept an async
validator but will not show a message while validating right now.

James Garbutt (Jul 2, 2026, 2:09 PM +0100) c87f6aee 8f1c3806

+209 -8
+6
.changeset/silly-bears-share.md
··· 1 + --- 2 + "@clack/prompts": minor 3 + "@clack/core": minor 4 + --- 5 + 6 + Add async validation support to prompts, and validation state rendering to text prompts.
+16 -2
packages/core/src/prompts/prompt.ts
··· 209 209 this._setUserInput(''); 210 210 } 211 211 212 - private onKeypress(char: string | undefined, key: Key) { 212 + private async onKeypress(char: string | undefined, key: Key) { 213 213 if (this._track && key.name !== 'return') { 214 214 if (key.name && this._isActionKey(char, key)) { 215 215 this.rl?.write(null, { ctrl: true, name: 'h' }); ··· 238 238 239 239 if (key?.name === 'return' && this._shouldSubmit(char, key)) { 240 240 if (this.opts.validate) { 241 - const problem = runValidation(this.opts.validate, this.value); 241 + const problemResult = runValidation(this.opts.validate, this.value); 242 + let problem: string | Error | undefined; 243 + // Only if it is not a string or an Error, we assume 244 + // it is a Promise and await it. 245 + if ( 246 + problemResult !== undefined && 247 + typeof problemResult !== 'string' && 248 + !(problemResult instanceof Error) 249 + ) { 250 + this.state = 'validating'; 251 + this.render(); 252 + problem = await problemResult; 253 + } else { 254 + problem = problemResult; 255 + } 242 256 if (problem) { 243 257 this.error = problem instanceof Error ? problem.message : problem; 244 258 this.state = 'error';
+2 -1
packages/core/src/types.ts
··· 4 4 /** 5 5 * The state of the prompt 6 6 */ 7 - export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error'; 7 + export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error' | 'validating'; 8 8 9 9 /** 10 10 * Typed event emitter for clack ··· 15 15 cancel: (value?: any) => void; 16 16 submit: (value?: any) => void; 17 17 error: (value?: any) => void; 18 + validating: (value?: any) => void; 18 19 cursor: (key?: Action) => void; 19 20 key: (key: string | undefined, info: Key) => void; 20 21 value: (value?: TValue) => void;
+5 -5
packages/core/src/utils/validation.ts
··· 1 1 import type { StandardSchemaV1 } from './standard-schema.js'; 2 2 3 + type MaybePromise<T> = T | Promise<T>; 4 + 3 5 /** 4 6 * A function or [Standard Schema](https://github.com/standard-schema/standard-schema) 5 7 * that validates user input. If a custom function is given, you should return a ··· 33 35 * ``` 34 36 */ 35 37 export type Validate<TValue> = 36 - | ((value: TValue | undefined) => string | Error | undefined) 38 + | ((value: TValue | undefined) => MaybePromise<string | Error | undefined>) 37 39 | StandardSchemaV1<TValue | undefined, unknown>; 38 40 39 41 /** ··· 45 47 export function runValidation<TValue>( 46 48 validate: Validate<TValue>, 47 49 value: TValue | undefined 48 - ): string | Error | undefined { 50 + ): MaybePromise<string | Error | undefined> { 49 51 if ('~standard' in validate) { 50 52 const result = validate['~standard'].validate(value); 51 53 // https://standardschema.dev/schema#how-to-only-allow-synchronous-validation 52 54 // TODO: https://github.com/bombshell-dev/clack/issues/92 53 55 if (result instanceof Promise) { 54 - throw new TypeError( 55 - 'Schema validation must be synchronous. Update `validate()` and remove any asynchronous logic.' 56 - ); 56 + return result.then((res) => res.issues?.at(0)?.message); 57 57 } 58 58 return result.issues?.at(0)?.message; 59 59 }
+44
packages/core/test/prompts/prompt.test.ts
··· 6 6 import { MockReadable } from '../mock-readable.js'; 7 7 import { MockWritable } from '../mock-writable.js'; 8 8 9 + const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0)); 10 + 9 11 describe('Prompt', () => { 10 12 let input: MockReadable; 11 13 let output: MockWritable; ··· 346 348 expect(instance.state).to.equal('error'); 347 349 expect(instance.error).to.equal('must be "valid" (was "invalid")'); 348 350 }); 351 + }); 352 + 353 + test('validates value with async validation', async () => { 354 + const instance = new Prompt<string>({ 355 + input, 356 + output, 357 + render: () => 'foo', 358 + validate: async (value) => { 359 + await waitForTick(); 360 + return value === 'valid' ? undefined : 'Invalid value'; 361 + }, 362 + }); 363 + instance.prompt(); 364 + 365 + instance.value = 'invalid'; 366 + input.emit('keypress', '', { name: 'return' }); 367 + 368 + expect(instance.state).to.equal('validating'); 369 + await waitForTick(); // Wait for the validation to complete 370 + expect(instance.state).to.equal('error'); 371 + expect(instance.error).to.equal('Invalid value'); 372 + }); 373 + 374 + test('accepts valid value with async validation', async () => { 375 + const instance = new Prompt<string>({ 376 + input, 377 + output, 378 + render: () => 'foo', 379 + validate: async (value) => { 380 + await waitForTick(); 381 + return value === 'valid' ? undefined : 'Invalid value'; 382 + }, 383 + }); 384 + instance.prompt(); 385 + 386 + instance.value = 'valid'; 387 + input.emit('keypress', '', { name: 'return' }); 388 + 389 + expect(instance.state).to.equal('validating'); 390 + await waitForTick(); // Wait for the validation to complete 391 + expect(instance.state).to.equal('submit'); 392 + expect(instance.error).to.equal(''); 349 393 }); 350 394 });
+2
packages/prompts/src/common.ts
··· 50 50 return styleText('yellow', S_STEP_ERROR); 51 51 case 'submit': 52 52 return styleText('green', S_STEP_SUBMIT); 53 + case 'validating': 54 + return styleText('dim', S_STEP_ACTIVE); 53 55 } 54 56 }; 55 57
+7
packages/prompts/src/text.ts
··· 75 75 const value = this.value ?? ''; 76 76 77 77 switch (this.state) { 78 + case 'validating': { 79 + const validatePrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''; 80 + const validatePrefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : ''; 81 + const userInputText = styleText('dim', userInput); 82 + const validateText = styleText('dim', 'Validating...'); 83 + return `${title}${validatePrefix}${userInputText}\n${validatePrefixEnd} ${validateText}\n`; 84 + } 78 85 case 'error': { 79 86 const errorText = this.error ? ` ${styleText('yellow', this.error)}` : ''; 80 87 const errorPrefix = hasGuide ? `${styleText('yellow', S_BAR)} ` : '';
+104
packages/prompts/test/__snapshots__/text.test.ts.snap
··· 52 52 ] 53 53 `; 54 54 55 + exports[`text (isCI = false) > displays a validating message with async validation 1`] = ` 56 + [ 57 + "<cursor.hide>", 58 + "│ 59 + ◆ foo 60 + │ _ 61 + └ 62 + ", 63 + "<cursor.backward count=999><cursor.up count=4>", 64 + "<cursor.down count=2>", 65 + "<erase.line><cursor.left count=1>", 66 + "│ x█", 67 + "<cursor.down count=2>", 68 + "<cursor.backward count=999><cursor.up count=4>", 69 + "<cursor.down count=1>", 70 + "<erase.down>", 71 + "◆ foo 72 + │ x█ 73 + └ Validating... 74 + ", 75 + "<cursor.backward count=999><cursor.up count=4>", 76 + "<cursor.down count=1>", 77 + "<erase.down>", 78 + "▲ foo 79 + │ x█ 80 + └ should be xy 81 + ", 82 + "<cursor.backward count=999><cursor.up count=4>", 83 + "<cursor.down count=1>", 84 + "<erase.down>", 85 + "◆ foo 86 + │ xy█ 87 + └ 88 + ", 89 + "<cursor.backward count=999><cursor.up count=4>", 90 + "<cursor.down count=1>", 91 + "<erase.down>", 92 + "◆ foo 93 + │ xy█ 94 + └ Validating... 95 + ", 96 + "<cursor.backward count=999><cursor.up count=4>", 97 + "<cursor.down count=1>", 98 + "<erase.down>", 99 + "◇ foo 100 + │ xy", 101 + " 102 + ", 103 + "<cursor.show>", 104 + ] 105 + `; 106 + 55 107 exports[`text (isCI = false) > empty string when no value and no default 1`] = ` 56 108 [ 57 109 "<cursor.hide>", ··· 343 395 "<erase.down>", 344 396 "◇ foo 345 397 │ bar", 398 + " 399 + ", 400 + "<cursor.show>", 401 + ] 402 + `; 403 + 404 + exports[`text (isCI = true) > displays a validating message with async validation 1`] = ` 405 + [ 406 + "<cursor.hide>", 407 + "│ 408 + ◆ foo 409 + │ _ 410 + └ 411 + ", 412 + "<cursor.backward count=999><cursor.up count=4>", 413 + "<cursor.down count=2>", 414 + "<erase.line><cursor.left count=1>", 415 + "│ x█", 416 + "<cursor.down count=2>", 417 + "<cursor.backward count=999><cursor.up count=4>", 418 + "<cursor.down count=1>", 419 + "<erase.down>", 420 + "◆ foo 421 + │ x█ 422 + └ Validating... 423 + ", 424 + "<cursor.backward count=999><cursor.up count=4>", 425 + "<cursor.down count=1>", 426 + "<erase.down>", 427 + "▲ foo 428 + │ x█ 429 + └ should be xy 430 + ", 431 + "<cursor.backward count=999><cursor.up count=4>", 432 + "<cursor.down count=1>", 433 + "<erase.down>", 434 + "◆ foo 435 + │ xy█ 436 + └ 437 + ", 438 + "<cursor.backward count=999><cursor.up count=4>", 439 + "<cursor.down count=1>", 440 + "<erase.down>", 441 + "◆ foo 442 + │ xy█ 443 + └ Validating... 444 + ", 445 + "<cursor.backward count=999><cursor.up count=4>", 446 + "<cursor.down count=1>", 447 + "<erase.down>", 448 + "◇ foo 449 + │ xy", 346 450 " 347 451 ", 348 452 "<cursor.show>",
+23
packages/prompts/test/text.test.ts
··· 238 238 239 239 expect(output.buffer).toMatchSnapshot(); 240 240 }); 241 + 242 + test('displays a validating message with async validation', async () => { 243 + const result = prompts.text({ 244 + message: 'foo', 245 + validate: async (val) => { 246 + await new Promise((resolve) => setTimeout(resolve, 10)); 247 + return val !== 'xy' ? 'should be xy' : undefined; 248 + }, 249 + input, 250 + output, 251 + }); 252 + 253 + input.emit('keypress', 'x', { name: 'x' }); 254 + input.emit('keypress', '', { name: 'return' }); 255 + await new Promise((resolve) => setTimeout(resolve, 20)); 256 + input.emit('keypress', 'y', { name: 'y' }); 257 + input.emit('keypress', '', { name: 'return' }); 258 + 259 + const value = await result; 260 + 261 + expect(value).toBe('xy'); 262 + expect(output.buffer).toMatchSnapshot(); 263 + }); 241 264 });