import { Arr, expectType, isString, PositiveInt, Result } from 'ts-data-forge'; import { type MaxLengthString, type MinLengthString, type NonEmptyString, type RelaxedExclude, type SupportedLength, } from 'ts-type-forge'; import { refine } from '../other-types/index.mjs'; import { type Type } from '../type.mjs'; import { createPrimitiveType, type StringConstraintViolation, } from '../utils/index.mjs'; export function string(defaultValue?: string): Type; export function string( defaultValue: S & DefaultValueType, constraints: C, ): Type>; export function string( defaultValue: string = '', constraints?: C, ): Type { const baseType = createPrimitiveType({ typeName: 'string', defaultValue, is: isString, }); if ( constraints === undefined || Arr.isFixedLengthTuple(Object.keys(constraints), 0) ) { return baseType; } const constraintsResult = validateConstraints(constraints); if (Result.isErr(constraintsResult)) { throw new TypeError(constraintsResult.value); } const constraintsPredicate = createConstraintsPredicate( constraintsResult.value, ); const defaultValueConstraintsCheck = constraintsPredicate(defaultValue); if (Result.isErr(defaultValueConstraintsCheck)) { throw new Error( defaultValueErrorMessage( defaultValue, defaultValueConstraintsCheck.value, ), ); } return refine({ baseType, defaultValue, is: (value): value is string => Result.isOk(constraintsPredicate(value)), typeName: 'string', getConstraintDetails: (value) => { const result = constraintsPredicate(value); return Result.isErr(result) ? { kind: 'string-constraint', violation: result.value } : undefined; }, }); } type RawConstraints = Partial< Readonly<{ nonempty: true; minLength: number; maxLength: number; startsWith: string; endsWith: string; includes: string; uppercase: true; lowercase: true; regex: RegExp; }> >; type Constraints = Partial< Readonly<{ nonempty: true; minLength: PositiveInt; maxLength: PositiveInt; startsWith: string; endsWith: string; includes: string; uppercase: true; lowercase: true; regex: RegExp; }> >; /** * The set of constraints accepted by {@link string}. */ export type StringTypeConstraints = RawConstraints; /** * The result type produced by {@link string} for a given set of constraints * `C` (e.g. `nonempty` yields {@link NonEmptyString}, `minLength` / * `maxLength` yield {@link MinLengthString} / {@link MaxLengthString}, * `startsWith` narrows to a template literal type, etc.). */ export type StringConstraintsResultType = ConstraintsResultType; type DefaultValueType< S extends string, C extends RawConstraints, > = DefaultValueWhenNonemptyIsOn & DefaultValueWhenMinLengthIsOn & DefaultValueWhenMaxLengthIsOn & DefaultValueWhenStartsWithIsOn & DefaultValueWhenEndsWithIsOn & DefaultValueWhenIncludesIsOn & DefaultValueWhenUppercaseIsOn & DefaultValueWhenLowercaseIsOn; type ConstraintsResultType = ConstraintsResultTypeWhenNonemptyIsOn & ConstraintsResultTypeWhenMinLengthIsOn & ConstraintsResultTypeWhenMaxLengthIsOn & DefaultValueWhenStartsWithIsOn & DefaultValueWhenEndsWithIsOn & DefaultValueWhenIncludesIsOn; type DefaultValueWhenStartsWithIsOn = C extends Readonly<{ startsWith: infer S extends string; }> ? `${S}${string}` : string; type DefaultValueWhenEndsWithIsOn = C extends Readonly<{ endsWith: infer E extends string; }> ? `${string}${E}` : string; type DefaultValueWhenIncludesIsOn = C extends Readonly<{ includes: infer M extends string; }> ? `${string}${M}${string}` : string; type DefaultValueWhenUppercaseIsOn = C extends Readonly<{ uppercase: true }> ? CastUppercase : string; type DefaultValueWhenLowercaseIsOn = C extends Readonly<{ lowercase: true }> ? CastLowercase : string; type CastLowercase = S extends Lowercase ? S : never; type CastUppercase = S extends Uppercase ? S : never; type DefaultValueWhenNonemptyIsOn = C extends Readonly<{ nonempty: true }> ? RejectEmptyString : string; type RejectEmptyString = S extends '' ? never : S; type ConstraintsResultTypeWhenNonemptyIsOn = C extends Readonly<{ nonempty: true }> ? NonEmptyString : string; /** * The length-bound literals supported at the type level: * `RelaxedExclude` (i.e. `1 | 2 | ... | 2048`, the shared * cap of the branded length-constrained types in ts-type-forge). Only bounds * within this range are encoded in the result brand; larger literals and * non-literal `number`s collapse to plain `string` to keep the type-level * computation cheap (the runtime constraint applies regardless). Non-integer * bounds and bounds less than 1 throw a `TypeError` when the type is * constructed. */ type SupportedLengthLiteral = RelaxedExclude; type ConstraintsResultTypeWhenMinLengthIsOn = C extends Readonly<{ minLength: infer M extends SupportedLengthLiteral }> ? MinLengthString : string; // minLength outside the supported literal range is not encoded in the brand type ConstraintsResultTypeWhenMaxLengthIsOn = C extends Readonly<{ maxLength: infer M extends SupportedLengthLiteral }> ? MaxLengthString : string; // maxLength outside the supported literal range is not encoded in the brand type DefaultValueWhenMinLengthIsOn = C extends Readonly<{ minLength: infer M extends SupportedLengthLiteral }> ? StringWithMinLength : string; // the default's length is only checked at runtime for such bounds type DefaultValueWhenMaxLengthIsOn = C extends Readonly<{ maxLength: infer M extends SupportedLengthLiteral }> ? StringWithMaxLength : string; // the default's length is only checked at runtime for such bounds type StringWithMinLength = HasLengthAtLeast extends true ? S : never; type StringWithMaxLength = HasLengthAtMost extends true ? S : never; type HasLengthAtLeast< S extends string, N extends number, Acc extends readonly unknown[] = readonly [], > = string extends S ? true : Acc['length'] extends N ? true : S extends '' ? false : S extends `${infer _}${infer Rest}` ? HasLengthAtLeast : false; type HasLengthAtMost< S extends string, N extends number, Acc extends readonly unknown[] = readonly [], > = string extends S ? true : S extends '' ? true : Acc['length'] extends N ? false : S extends `${infer _}${infer Rest}` ? HasLengthAtMost : false; const validateConstraints = ( constraints: RawConstraints, ): Result => { const result1 = validateLengthConstraint('minLength', constraints.minLength); if (Result.isErr(result1)) { return Result.err(result1.value); } const result2 = validateLengthConstraint('maxLength', constraints.maxLength); if (Result.isErr(result2)) { return Result.err(result2.value); } // eslint-disable-next-line total-functions/no-unsafe-type-assertion return Result.ok(constraints as Constraints); }; const validateLengthConstraint = ( constraintName: 'maxLength' | 'minLength', constraintValue: number | undefined, ): Result => { if (constraintValue !== undefined && !PositiveInt.is(constraintValue)) { return Result.err( `${constraintName} constraint for string must be a positive integer, but ${constraintValue} was passed.`, ); } return Result.ok(true); }; const createConstraintsPredicate = (constraints: Constraints) => (value: string): Result => { const { nonempty, minLength, maxLength, startsWith, endsWith, includes, uppercase, lowercase, regex, ..._rest } = constraints; expectType('='); if (nonempty === true && value.length === 0) { return Result.err({ constraint: 'nonempty', value: true } as const); } if (minLength !== undefined && value.length < minLength) { return Result.err({ constraint: 'minLength', value: minLength } as const); } if (maxLength !== undefined && value.length > maxLength) { return Result.err({ constraint: 'maxLength', value: maxLength } as const); } if (startsWith !== undefined && !value.startsWith(startsWith)) { return Result.err({ constraint: 'startsWith', value: startsWith, } as const); } if (endsWith !== undefined && !value.endsWith(endsWith)) { return Result.err({ constraint: 'endsWith', value: endsWith } as const); } if (includes !== undefined && !value.includes(includes)) { return Result.err({ constraint: 'includes', value: includes } as const); } if (uppercase === true && value !== value.toUpperCase()) { return Result.err({ constraint: 'uppercase', value: true } as const); } if (lowercase === true && value !== value.toLowerCase()) { return Result.err({ constraint: 'lowercase', value: true } as const); } if (regex !== undefined && !regex.test(value)) { return Result.err({ constraint: 'regex', value: regex.source } as const); } return Result.ok(true); }; const defaultValueErrorMessage = ( value: string, violation: StringConstraintViolation, ): string => `defaultValue "${value}" for string does not satisfy the constraint ${violation.constraint} = ${violation.value}` as const;