diff --git a/README.md b/README.md index 4003e55..f21032b 100644 --- a/README.md +++ b/README.md @@ -399,6 +399,8 @@ seven.is(8); // false - [`never`](#never) - [`number`](#number) - [`number.satisfying`](#numbersatisfying) + - [`string`](#string) + - [`string.satisfying`](#stringsatisfying) - [`string`](#string) - [`symbol`](#symbol) - [`union`](#union) @@ -919,6 +921,46 @@ string.is(""); // true --- +##### `string.satisfying` + +Only allow strings that satisfy the specified constraints. + +```ts +const NonEmptyString = string.satisfying({ length: { min: 1 } }); + +NonEmptyString.as("hello"); // "hello" + +NonEmptyString.as(""); +// TypeError: String length is invalid: Number is less than the minimum of 1. +``` + +Here, the length constraint is an object accepted by +[number.satisfying](#numbersatisfying); it can also be a number indicating the exact +length, or a Cake. + +Strings matching a regular expression (use `^` and `$` to match +the entire string): + +```ts +const HexString = string.satisfying({ regex: /^[0-9a-f]+$/ }); + +HexString.as("123abc"); // "123abc" + +HexString.as("oops"); +// TypeError: String does not match regex /^[0-9a-f]+$/. +``` + + + +A [Cake](#cake) representing the `string` type. + +```ts +string.is("hello"); // true +string.is(""); // true +``` + +--- + #### `symbol` A [Cake](#cake) representing the `symbol` type. @@ -2092,8 +2134,9 @@ const c: Class = Date; #### Added -- [number.satisfying](#numbersatisfying) ([#67](https://github.com/justinyaodu/caketype/pull/67)) - [integer](#integer) Cake and refinements ([#65](https://github.com/justinyaodu/caketype/pull/65)) +- [number.satisfying](#numbersatisfying) ([#67](https://github.com/justinyaodu/caketype/pull/67)) +- [string.satisfying](#stringsatisfying) ([#68](https://github.com/justinyaodu/caketype/pull/68)) #### Changed diff --git a/etc/caketype.api.md b/etc/caketype.api.md index adca9ac..80b3747 100644 --- a/etc/caketype.api.md +++ b/etc/caketype.api.md @@ -639,11 +639,62 @@ export const Result: ResultUtils; export function sameValueZero(a: unknown, b: unknown): boolean; // @public -export const string: Cake; +export const string: StringCake; + +// @public +export class StringCake extends Cake { + // (undocumented) + dispatchCheck(value: unknown, context: CakeDispatchCheckContext): CakeError | null; + // (undocumented) + dispatchStringify(context: CakeDispatchStringifyContext): string; + // (undocumented) + refined>(refinement: R): StringRefinementCake; + // Warning: (ae-unresolved-link) The @link reference could not be resolved: No member was found with name "satisfying" + satisfying(constraints: StringConstraints): StringRefinementCake; + // (undocumented) + withName(name: string | null): StringCake; +} + +// @public (undocumented) +export interface StringConstraints { + // (undocumented) + length?: number | NumberConstraints | Cake; + // (undocumented) + regex?: RegExp; +} + +// @public (undocumented) +export class StringConstraintsRefinement extends Refinement implements StringConstraintsRefinementArgs { + constructor(args: StringConstraintsRefinementArgs); + // (undocumented) + readonly constraints: StringConstraints; + // (undocumented) + dispatchCheck(value: string): CakeError | null; + // (undocumented) + toString(): string; +} + +// @public (undocumented) +export interface StringConstraintsRefinementArgs { + // (undocumented) + constraints: StringConstraints; +} // @public export function stringifyPrimitive(value: Primitive): string; +// @public (undocumented) +export class StringRefinementCake, R extends Refinement> extends RefinementCake { + // (undocumented) + refined

>(refinement: R): StringRefinementCake; + // Warning: (ae-unresolved-link) The @link reference could not be resolved: No member was found with name "satisfying" + // + // (undocumented) + satisfying(constraints: StringConstraints): StringRefinementCake; + // (undocumented) + withName(name: string | null): StringRefinementCake; +} + // @public (undocumented) export type StringTree = string | readonly [string, readonly StringTree[]]; diff --git a/src/cake/StringCake.ts b/src/cake/StringCake.ts new file mode 100644 index 0000000..7467c19 --- /dev/null +++ b/src/cake/StringCake.ts @@ -0,0 +1,99 @@ +import { + Cake, + CakeDispatchCheckContext, + CakeDispatchStringifyContext, + CakeError, + StringConstraints, + StringConstraintsRefinement, + StringRefinementCake, + Refinement, + WrongTypeCakeError, +} from "./index-internal"; +import type { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + number, +} from "./index-internal"; + +/** + * See {@link string}. + * + * @public + */ +class StringCake extends Cake { + /** + * Only allow strings that satisfy the specified constraints. + * + * @example + * + * ```ts + * const NonEmptyString = string.satisfying({ length: { min: 1 } }); + * + * NonEmptyString.as("hello"); // "hello" + * + * NonEmptyString.as(""); + * // TypeError: String length is invalid: Number is less than the minimum of 1. + * ``` + * + * Here, the length constraint is an object accepted by + * {@link number.satisfying}; it can also be a number indicating the exact + * length, or a Cake. + * + * @example Strings matching a regular expression (use `^` and `$` to match + * the entire string): + * + * ```ts + * const HexString = string.satisfying({ regex: /^[0-9a-f]+$/ }); + * + * HexString.as("123abc"); // "123abc" + * + * HexString.as("oops"); + * // TypeError: String does not match regex /^[0-9a-f]+$/. + * ``` + */ + satisfying( + constraints: StringConstraints + ): StringRefinementCake { + return this.refined(new StringConstraintsRefinement({ constraints })); + } + + override refined>( + refinement: R + ): StringRefinementCake { + return new StringRefinementCake({ base: this, refinement }); + } + + dispatchCheck( + value: unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + context: CakeDispatchCheckContext + ): CakeError | null { + if (typeof value !== "string") { + return new WrongTypeCakeError(this, value); + } + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + dispatchStringify(context: CakeDispatchStringifyContext): string { + return "string"; + } + + withName(name: string | null): StringCake { + return new StringCake({ ...this, name }); + } +} + +/** + * A {@link Cake} representing the `string` type. + * + * @example + * ```ts + * string.is("hello"); // true + * string.is(""); // true + * ``` + * + * @public + */ +const string = new StringCake({}); + +export { StringCake, string }; diff --git a/src/cake/StringConstraintsRefinement.ts b/src/cake/StringConstraintsRefinement.ts new file mode 100644 index 0000000..eda1319 --- /dev/null +++ b/src/cake/StringConstraintsRefinement.ts @@ -0,0 +1,110 @@ +import { + bake, + Cake, + CakeError, + CakeErrorDispatchFormatContext, + NumberConstraints, + NumberConstraintsRefinement, + prependStringTree, + Refinement, + StringTree, +} from "./index-internal"; + +/** + * @public + */ +interface StringConstraints { + length?: number | NumberConstraints | Cake; + regex?: RegExp; +} + +/** + * @public + */ +interface StringConstraintsRefinementArgs { + constraints: StringConstraints; +} + +/** + * @public + */ +class StringConstraintsRefinement + extends Refinement + implements StringConstraintsRefinementArgs +{ + readonly constraints: StringConstraints; + private readonly lengthSpec: NumberConstraintsRefinement | Cake | null; + + constructor(args: StringConstraintsRefinementArgs) { + super(); + this.constraints = args.constraints; + + const rawLengthSpec = args.constraints.length; + if (rawLengthSpec === undefined) { + this.lengthSpec = null; + } else if (typeof rawLengthSpec === "number") { + this.lengthSpec = bake(rawLengthSpec); + } else if (rawLengthSpec instanceof Cake) { + this.lengthSpec = rawLengthSpec; + } else { + this.lengthSpec = new NumberConstraintsRefinement({ + constraints: rawLengthSpec, + }); + } + } + + dispatchCheck(value: string): CakeError | null { + if (this.lengthSpec !== null) { + const result = this.lengthSpec.check(value.length); + if (!result.ok) { + return new StringLengthCakeError(value, result.error); + } + } + + const regex = this.constraints.regex; + if (regex !== undefined && !regex.test(value)) { + return new RegexNotMatchedCakeError(value, regex); + } + + return null; + } + + toString(): string { + const parts: string[] = []; + if (this.lengthSpec !== null) { + parts.push(`length ${this.lengthSpec}`); + } + if (this.constraints.regex !== undefined) { + parts.push(`regex ${this.constraints.regex}`); + } + return parts.join(", "); + } +} + +class StringLengthCakeError extends CakeError { + constructor(readonly value: string, readonly error: CakeError) { + super(); + } + + dispatchFormat(context: CakeErrorDispatchFormatContext): StringTree { + const { recurse } = context; + return prependStringTree("String length is invalid: ", recurse(this.error)); + } +} + +class RegexNotMatchedCakeError extends CakeError { + constructor(readonly value: string, readonly regex: RegExp) { + super(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + dispatchFormat(context: CakeErrorDispatchFormatContext): StringTree { + return `String does not match regex ${this.regex}.`; + } +} + +export { + StringConstraints, + StringConstraintsRefinement, + StringConstraintsRefinementArgs, +}; diff --git a/src/cake/StringRefinementCake.ts b/src/cake/StringRefinementCake.ts new file mode 100644 index 0000000..85e72f3 --- /dev/null +++ b/src/cake/StringRefinementCake.ts @@ -0,0 +1,42 @@ +import { + Cake, + StringConstraints, + StringConstraintsRefinement, + Refinement, + RefinementCake, +} from "./index-internal"; +import type { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + string, +} from "./index-internal"; + +/** + * @public + */ +class StringRefinementCake< + I extends string, + O extends I, + B extends Cake, + R extends Refinement +> extends RefinementCake { + /** + * @see {@link string.satisfying}. + */ + satisfying( + constraints: StringConstraints + ): StringRefinementCake { + return this.refined(new StringConstraintsRefinement({ constraints })); + } + + override refined

>( + refinement: R + ): StringRefinementCake { + return new StringRefinementCake({ base: this, refinement }); + } + + override withName(name: string | null): StringRefinementCake { + return new StringRefinementCake({ ...this, name }); + } +} + +export { StringRefinementCake }; diff --git a/src/cake/TypeGuardCake.ts b/src/cake/TypeGuardCake.ts index b9ca9c6..7632785 100644 --- a/src/cake/TypeGuardCake.ts +++ b/src/cake/TypeGuardCake.ts @@ -3,7 +3,6 @@ import { is_boolean, is_bigint, is_never, - is_string, is_symbol, is_unknown, } from "../index-internal"; @@ -140,19 +139,6 @@ const bigint: Cake = typeGuard("bigint", is_bigint); */ const never: Cake = typeGuard("never", is_never); -/** - * A {@link Cake} representing the `string` type. - * - * @example - * ```ts - * string.is("hello"); // true - * string.is(""); // true - * ``` - * - * @public - */ -const string: Cake = typeGuard("string", is_string); - /** * A {@link Cake} representing the `symbol` type. * @@ -192,7 +178,6 @@ export { boolean, bigint, never, - string, symbol, unknown, }; diff --git a/src/cake/index-internal.ts b/src/cake/index-internal.ts index 1620f6f..6cb578d 100644 --- a/src/cake/index-internal.ts +++ b/src/cake/index-internal.ts @@ -23,8 +23,11 @@ export * from "./Refinement"; export * from "./RefinementCake"; export * from "./NumberCake"; export * from "./NumberRefinementCake"; +export * from "./StringCake"; +export * from "./StringRefinementCake"; export * from "./IntegerRefinement"; export * from "./NumberConstraintsRefinement"; +export * from "./StringConstraintsRefinement"; export * from "./helper-types"; diff --git a/src/cake/index.ts b/src/cake/index.ts index fe55aa8..833d02c 100644 --- a/src/cake/index.ts +++ b/src/cake/index.ts @@ -52,6 +52,15 @@ export { // RefinementCake.ts RefinementCake, RefinementCakeArgs, + // StringCake.ts + StringCake, + string, + // StringConstraintsRefinement.ts + StringConstraints, + StringConstraintsRefinement, + StringConstraintsRefinementArgs, + // StringRefinementCake.ts + StringRefinementCake, // StringTree.ts StringTree, // tags.ts @@ -73,7 +82,6 @@ export { boolean, bigint, never, - string, symbol, unknown, // UnionCake.ts diff --git a/src/type-guards.ts b/src/type-guards.ts index bab1129..8c3096d 100644 --- a/src/type-guards.ts +++ b/src/type-guards.ts @@ -26,10 +26,6 @@ function is_object(value: unknown): value is object { } } -function is_string(value: unknown): value is string { - return typeof value === "string"; -} - function is_symbol(value: unknown): value is symbol { return typeof value === "symbol"; } @@ -44,7 +40,6 @@ export { is_boolean, is_never, is_object, - is_string, is_symbol, is_unknown, }; diff --git a/tests/cake/Baker.test.ts b/tests/cake/Baker.test.ts index 6d58c6e..222aa6f 100644 --- a/tests/cake/Baker.test.ts +++ b/tests/cake/Baker.test.ts @@ -1,7 +1,6 @@ import { Assert, bake, - Cake, Equivalent, Infer, number, @@ -21,7 +20,7 @@ describe("documentation examples", () => { Equivalent< typeof Person, ObjectCake<{ - name: Cake; + name: typeof string; age: OptionalTag; }> > diff --git a/tests/cake/Cake-withName.test.ts b/tests/cake/Cake-withName.test.ts index b17262a..9664467 100644 --- a/tests/cake/Cake-withName.test.ts +++ b/tests/cake/Cake-withName.test.ts @@ -6,6 +6,7 @@ import { keysUnsound, number, reference, + string, TupleCake, TypeGuardCake, union, @@ -19,6 +20,8 @@ const cakes = { object: bake({}), reference: reference(() => boolean), refinement: bake(0).refined(integer.refinement), + string: string, + stringRefinement: string.satisfying({}).satisfying({}), tuple: new TupleCake({ startElements: [], optionalElements: [], diff --git a/tests/cake/StringCake.test.ts b/tests/cake/StringCake.test.ts new file mode 100644 index 0000000..5ee3ead --- /dev/null +++ b/tests/cake/StringCake.test.ts @@ -0,0 +1,59 @@ +import { integer, string } from "../../src"; +import { expectTypeError } from "../test-helpers"; + +describe("documentation examples", () => { + test("string", () => { + expect(string.is("hello")).toStrictEqual(true); + expect(string.is("")).toStrictEqual(true); + }); + + test("satisfying", () => { + const NonEmptyString = string.satisfying({ length: { min: 1 } }); + + expect(NonEmptyString.as("hello")).toStrictEqual("hello"); + + expectTypeError( + () => NonEmptyString.as(""), + "String length is invalid: Number is less than the minimum of 1." + ); + + const HexString = string.satisfying({ regex: /^[0-9a-f]+$/ }); + + expect(HexString.as("123abc")).toStrictEqual("123abc"); + + expectTypeError( + () => HexString.as("oops"), + "String does not match regex /^[0-9a-f]+$/." + ); + + // Test toString + + expect(NonEmptyString.toString()).toStrictEqual( + "(string).refined(length min 1)" + ); + expect(HexString.toString()).toStrictEqual( + "(string).refined(regex /^[0-9a-f]+$/)" + ); + }); +}); + +test("length constant", () => { + const FiveCharacterString = string.satisfying({ length: 5 }); + expect(FiveCharacterString.is("hello")).toStrictEqual(true); + expectTypeError( + () => FiveCharacterString.as("oops"), + "String length is invalid: Value does not equal 5." + ); +}); + +test("length Cake", () => { + const EvenLengthString = string.satisfying({ + length: integer.satisfying({ step: 2 }), + }); + + expect(EvenLengthString.is("hi")).toStrictEqual(true); + expectTypeError( + () => EvenLengthString.as("hello"), + "String length is invalid: Number is not a multiple of 2." + ); +}); diff --git a/tests/cake/TypeGuardCake.test.ts b/tests/cake/TypeGuardCake.test.ts index fa82319..022889c 100644 --- a/tests/cake/TypeGuardCake.test.ts +++ b/tests/cake/TypeGuardCake.test.ts @@ -33,11 +33,6 @@ describe("documentation examples", () => { expect(never.is(undefined)).toStrictEqual(false); }); - test("string", () => { - expect(string.is("hello")).toStrictEqual(true); - expect(string.is("")).toStrictEqual(true); - }); - test("symbol", () => { expect(symbol.is(Symbol.iterator)).toStrictEqual(true); expect(symbol.is(Symbol("hi"))).toStrictEqual(true);