Skip to content

Commit

Permalink
Add string.satisfying (#68)
Browse files Browse the repository at this point in the history
  • Loading branch information
justinyaodu authored Feb 11, 2023
1 parent 84e8b56 commit 7c4d54b
Show file tree
Hide file tree
Showing 13 changed files with 422 additions and 30 deletions.
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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]+$/.
```

<!-- const string = new StringCake({}); -->

A [Cake](#cake) representing the `string` type.

```ts
string.is("hello"); // true
string.is(""); // true
```

---

#### `symbol`

A [Cake](#cake) representing the `symbol` type.
Expand Down Expand Up @@ -2092,8 +2134,9 @@ const c: Class<Date, [number]> = 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

Expand Down
53 changes: 52 additions & 1 deletion etc/caketype.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,11 +639,62 @@ export const Result: ResultUtils;
export function sameValueZero(a: unknown, b: unknown): boolean;

// @public
export const string: Cake<string>;
export const string: StringCake;

// @public
export class StringCake extends Cake<string> {
// (undocumented)
dispatchCheck(value: unknown, context: CakeDispatchCheckContext): CakeError | null;
// (undocumented)
dispatchStringify(context: CakeDispatchStringifyContext): string;
// (undocumented)
refined<O extends string, R extends Refinement<string, O>>(refinement: R): StringRefinementCake<string, O, this, R>;
// Warning: (ae-unresolved-link) The @link reference could not be resolved: No member was found with name "satisfying"
satisfying(constraints: StringConstraints): StringRefinementCake<string, string, this, StringConstraintsRefinement>;
// (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<string> 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<I extends string, O extends I, B extends Cake<I>, R extends Refinement<I, O>> extends RefinementCake<I, O, B, R> {
// (undocumented)
refined<P extends O, R extends Refinement<O, P>>(refinement: R): StringRefinementCake<O, P, this, R>;
// Warning: (ae-unresolved-link) The @link reference could not be resolved: No member was found with name "satisfying"
//
// (undocumented)
satisfying(constraints: StringConstraints): StringRefinementCake<O, O, this, StringConstraintsRefinement>;
// (undocumented)
withName(name: string | null): StringRefinementCake<I, O, B, R>;
}

// @public (undocumented)
export type StringTree = string | readonly [string, readonly StringTree[]];

Expand Down
99 changes: 99 additions & 0 deletions src/cake/StringCake.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
/**
* 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<string, string, this, StringConstraintsRefinement> {
return this.refined(new StringConstraintsRefinement({ constraints }));
}

override refined<O extends string, R extends Refinement<string, O>>(
refinement: R
): StringRefinementCake<string, O, this, R> {
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 };
110 changes: 110 additions & 0 deletions src/cake/StringConstraintsRefinement.ts
Original file line number Diff line number Diff line change
@@ -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<string>
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,
};
42 changes: 42 additions & 0 deletions src/cake/StringRefinementCake.ts
Original file line number Diff line number Diff line change
@@ -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<I>,
R extends Refinement<I, O>
> extends RefinementCake<I, O, B, R> {
/**
* @see {@link string.satisfying}.
*/
satisfying(
constraints: StringConstraints
): StringRefinementCake<O, O, this, StringConstraintsRefinement> {
return this.refined(new StringConstraintsRefinement({ constraints }));
}

override refined<P extends O, R extends Refinement<O, P>>(
refinement: R
): StringRefinementCake<O, P, this, R> {
return new StringRefinementCake({ base: this, refinement });
}

override withName(name: string | null): StringRefinementCake<I, O, B, R> {
return new StringRefinementCake({ ...this, name });
}
}

export { StringRefinementCake };
Loading

0 comments on commit 7c4d54b

Please sign in to comment.