Skip to content
Open
20 changes: 20 additions & 0 deletions .changeset/social-lands-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@clack/prompts": minor
"@clack/core": minor
---

Adds support for Standard Schema validation

Prompts accept an optional `validate()` function to validate user input. While a function provides more flexibility and customization over your validation, it can be a bit verbose. To help solve this, there are libraries that provide schema-based validation to make shorthand and type-strict validation substantially easier.

Libraries following the [Standard Schema specification](https://github.com/standard-schema/standard-schema) are now natively supported. For example, using [Arktype](https://arktype.io/):

```diff
import { text } from '@clack/prompts';
import { type } from 'arktype';

const name = await text({
message: 'Enter your email',
+ validate: type('string.email').describe('Invalid email'),
});
```
1 change: 1 addition & 0 deletions examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"dependencies": {
"@clack/prompts": "workspace:*",
"arktype": "^2.2.0",
"picocolors": "^1.0.0",
"jiti": "^1.17.0"
},
Expand Down
35 changes: 35 additions & 0 deletions examples/basic/standard-schema-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { setTimeout } from 'node:timers/promises';
import { isCancel, note, text } from '@clack/prompts';
import { type } from 'arktype';

async function main() {
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
console.clear();

// Example demonstrating the issue with initial value validation
const name = await text({
message: 'Enter your email',
initialValue: 'aaa', // Invalid initial value without @
validate: type('string.email').describe('Invalid email'),
});

if (!isCancel(name)) {
note(`Valid name: ${name}`, 'Success');
}

await setTimeout(1000);

// Example with a valid initial value for comparison
const validName = await text({
message: 'Enter another email',
initialValue: 'john.doe@example.com', // Valid initial value
validate: type('string.email').describe('Invalid email'),
});

if (!isCancel(validName)) {
note(`Valid name: ${validName}`, 'Success');
}

await setTimeout(1000);
}

await main().catch(console.error);
2 changes: 1 addition & 1 deletion examples/basic/text-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ async function main() {
await setTimeout(1000);
}

main().catch(console.error);
await main().catch(console.error);
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"sisteransi": "^1.0.5"
},
"devDependencies": {
"arktype": "^2.2.0",
"vitest": "^3.2.4"
}
}
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@ export type { ClackState as State } from './types.js';
export { block, getColumns, getRows, isCancel, wrapTextWithPrefix } from './utils/index.js';
export type { ClackSettings } from './utils/settings.js';
export { settings, updateSettings } from './utils/settings.js';
export type { Validate } from './utils/validation.js';
export { runValidation } from './utils/validation.js';
12 changes: 10 additions & 2 deletions packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,20 @@ import {
setRawMode,
settings,
} from '../utils/index.js';
import type { Validate } from '../utils/validation.js';
import { runValidation } from '../utils/validation.js';

export interface PromptOptions<TValue, Self extends Prompt<TValue>> {
render(this: Omit<Self, 'prompt'>): string | undefined;
initialValue?: any;
initialUserInput?: string;
validate?: ((value: TValue | undefined) => string | Error | undefined) | undefined;

/**
* A function or a [Standard Schema](https://github.com/standard-schema/standard-schema)
* that validates user input. Return a `string` or `Error` to show as a validation error,
* or `undefined` to accept the result.
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
*/
validate?: Validate<TValue> | undefined;
input?: Readable;
output?: Writable;
signal?: AbortSignal;
Expand Down Expand Up @@ -230,7 +238,7 @@ export default class Prompt<TValue> {

if (key?.name === 'return' && this._shouldSubmit(char, key)) {
if (this.opts.validate) {
const problem = this.opts.validate(this.value);
const problem = runValidation(this.opts.validate, this.value);
if (problem) {
this.error = problem instanceof Error ? problem.message : problem;
this.state = 'error';
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/utils/standard-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// https://standardschema.dev/schema

/** The Standard Schema interface. */
export interface StandardSchemaV1<Input = unknown, Output = Input> {
/** The Standard Schema properties. */
readonly "~standard": StandardSchemaV1.Props<Input, Output>;
}

export declare namespace StandardSchemaV1 {
/** The Standard Schema properties interface. */
export interface Props<Input = unknown, Output = Input> {
/** The version number of the standard. */
readonly version: 1;
/** The vendor name of the schema library. */
readonly vendor: string;
/** Validates unknown input values. */
readonly validate: (
value: unknown,
options?: StandardSchemaV1.Options | undefined,
) => Result<Output> | Promise<Result<Output>>;
/** Inferred types associated with the schema. */
readonly types?: Types<Input, Output> | undefined;
}

/** The result interface of the validate function. */
export type Result<Output> = SuccessResult<Output> | FailureResult;

/** The result interface if validation succeeds. */
export interface SuccessResult<Output> {
/** The typed output value. */
readonly value: Output;
/** A falsy value for `issues` indicates success. */
readonly issues?: undefined;
}

export interface Options {
/** Explicit support for additional vendor-specific parameters, if needed. */
readonly libraryOptions?: Record<string, unknown> | undefined;
}

/** The result interface if validation fails. */
export interface FailureResult {
/** The issues of failed validation. */
readonly issues: ReadonlyArray<Issue>;
}

/** The issue interface of the failure output. */
export interface Issue {
/** The error message of the issue. */
readonly message: string;
/** The path of the issue, if any. */
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
}

/** The path segment interface of the issue. */
export interface PathSegment {
/** The key representing a path segment. */
readonly key: PropertyKey;
}

/** The Standard Schema types interface. */
export interface Types<Input = unknown, Output = Input> {
/** The input type of the schema. */
readonly input: Input;
/** The output type of the schema. */
readonly output: Output;
}

/** Infers the input type of a Standard Schema. */
export type InferInput<Schema extends StandardSchemaV1> = NonNullable<
Schema["~standard"]["types"]
>["input"];

/** Infers the output type of a Standard Schema. */
export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<
Schema["~standard"]["types"]
>["output"];
}
35 changes: 35 additions & 0 deletions packages/core/src/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { StandardSchemaV1 } from './standard-schema.js';

/**
* Represents the `validate()` option. A function or a
* [Standard Schema](https://github.com/standard-schema/standard-schema)
* that validates user input. Return a `string` or `Error` to show as a
* validation error, or `undefined` to accept the result.
*/
export type Validate<TValue> =
Comment thread
florian-lefebvre marked this conversation as resolved.
| ((value: TValue | undefined) => string | Error | undefined)
| StandardSchemaV1<TValue | undefined, unknown>;

/**
* Runs the `validate()` option and normalizes the result
* @param validate - The validate option
* @param value - The user input
* @returns string | Error | undefined
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
*/
export function runValidation<TValue>(
validate: Validate<TValue>,
value: TValue | undefined
): string | Error | undefined {
if ('~standard' in validate) {
const result = validate['~standard'].validate(value);
// https://standardschema.dev/schema#how-to-only-allow-synchronous-validation
// TODO: https://github.com/bombshell-dev/clack/issues/92
if (result instanceof Promise) {
throw new TypeError(
'Schema validation must be synchronous. Update `validate()` and remove any asynchronous logic.'
);
}
return result.issues?.at(0)?.message;
}
return validate(value);
}
Loading
Loading