Created
August 4, 2025 15:46
-
-
Save b2whats/d7d11a9f76abde43d419dda271cb22d3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable @typescript-eslint/no-explicit-any */ | |
type FormMetadata = { | |
fields: FieldsRules | |
options?: { | |
errorFieldName: string | symbol | |
} & Required<ErrorField> | |
touchedFields?: WeakMap<any, Set<string | symbol>> | |
} | |
type FieldsRules = Map<string | symbol, FieldsRule> | |
type FieldsRule = { | |
name: string | symbol | |
value: any | |
clonable: boolean | |
size?: number | |
validator: Validator | |
} | |
type ErrorField = { | |
multiple?: boolean | |
format?: 'object' | 'array' | |
fieldsObserver?: (instance: any, callback: (key: string | symbol, value: any) => void) => void | |
} | |
const unknownValue = Symbol('unknownValue') | |
// @ts-ignore | |
Symbol.metadata ??= Symbol('Symbol.metadata') | |
function Field<This, Value>(rules: ((v: Validator<Value, This>) => Validator) | ErrorField) { | |
return (_: undefined, context: ClassFieldDecoratorContext<This, Value>) => { | |
const { kind, name, metadata } = context | |
if (kind !== 'field') throw new Error('Only fields can be decorated') | |
const fields = (metadata.fields ??= new Map()) as FieldsRules | |
const touchedFields = (metadata.touchedFields ??= new WeakMap()) as WeakMap<any, Set<string | symbol>> | |
if (typeof rules === 'object') { | |
const options = (metadata.options = { | |
errorFieldName: name, | |
multiple: rules.multiple ?? false, | |
format: rules.format ?? 'object', | |
}) | |
if (rules.fieldsObserver !== undefined) { | |
context.addInitializer(function (this: any) { | |
const { multiple, format } = options | |
const onChangeFields = new Map<string | symbol, Validator>() | |
for (const [key, { validator }] of fields) { | |
if (validator.when === 'change') { | |
onChangeFields.set(key, validator) | |
} | |
for (const fieldName of validator.dependent) { | |
fields.get(fieldName)?.validator?.dependencies.add(key) | |
} | |
} | |
if (onChangeFields.size === 0) return | |
const touched = new Set<string | symbol>() | |
touchedFields.set(this, touched) | |
const setError = (key: string | symbol, error: FieldError | undefined) => { | |
if (format === 'object') { | |
this[name][key] = error && errorsToObject([error], multiple)[key] | |
} else { | |
this[name] = this[name].filter((error: FieldError) => error.property !== key) | |
if (error) this[name].push(error) | |
} | |
} | |
rules.fieldsObserver?.(this, (key, value) => { | |
touched.add(key) | |
const validator = onChangeFields.get(key) | |
if (validator === undefined) return | |
setError(key, validator.validate(value, this)) | |
for (const key of validator.dependencies) { | |
const value = this[key] | |
const validator = fields.get(key)?.validator | |
if (validator === undefined || touched.has(key) === false) continue | |
setError(key, validator.validate(value, this)) | |
} | |
}) | |
}) | |
} | |
return | |
} | |
const descriptor: FieldsRule = { | |
name, | |
value: unknownValue, | |
clonable: true, | |
validator: rules(new Validator(name)), | |
} | |
fields.set(name, descriptor) | |
return function (value: any) { | |
if (descriptor.value !== unknownValue) return value | |
descriptor.size = | |
value instanceof Map || value instanceof Set ? value.size : Array.isArray(value) ? value.length : undefined | |
try { | |
const clone = structuredClone(value) | |
descriptor.value = clone | |
descriptor.clonable = true | |
} catch { | |
descriptor.value = value | |
descriptor.clonable = false | |
} | |
return value | |
} | |
} | |
} | |
type ValidateInstance = { | |
[key: string | symbol]: any | |
} | |
type FormObjectErrors = Record<string | symbol, any> | |
type FormErrors = FieldError[] | |
function validate(instance: ValidateInstance): FormErrors | undefined { | |
const metadata = instance?.constructor?.[Symbol.metadata] as FormMetadata | undefined | |
const fields = metadata?.fields | |
if (fields === undefined) return undefined | |
let errors: FormErrors | undefined | |
for (const [key, { validator }] of fields) { | |
const value = instance[key] | |
let error = validator.validate(value, instance) | |
if (error !== undefined && validator.isNested === false) (errors ??= []).push(error) | |
if (validator.isNested === false) continue | |
if (error === undefined) error = { property: key, children: [] } | |
if (value instanceof Set || Array.isArray(value)) { | |
Array.from(value).forEach((item, index) => { | |
const nestedError = validate(item) | |
if (nestedError !== undefined) { | |
;(error.children ??= []).push({ | |
property: index, | |
children: nestedError, | |
}) | |
} | |
}) | |
} | |
if (value instanceof Map) { | |
Array.from(value.entries()).forEach(([key, value]) => { | |
const nestedError = validate(value) | |
if (nestedError !== undefined) { | |
;(error.children ??= []).push({ | |
property: key, | |
children: nestedError, | |
}) | |
} | |
}) | |
} | |
if (value instanceof Object) { | |
const nestedError = validate(value) | |
if (nestedError !== undefined) { | |
error.children = nestedError | |
} | |
} | |
if (error.children?.length !== 0) (errors ??= []).push(error) | |
} | |
if (metadata?.options !== undefined) { | |
const { errorFieldName, multiple, format } = metadata.options | |
instance[errorFieldName] = format === 'object' ? errorsToObject(errors, multiple) : errors | |
} | |
return errors | |
} | |
function reset(instance: ValidateInstance): void { | |
const metadata = instance?.constructor?.[Symbol.metadata] as FormMetadata | undefined | |
const fields = metadata?.fields | |
const options = metadata?.options | |
const touchedFields = metadata?.touchedFields?.get(instance) | |
if (fields === undefined) return | |
for (const [key, { value, clonable, size }] of fields) { | |
instance[key] = value | |
if (clonable) continue | |
if (Array.isArray(instance[key])) { | |
instance[key] = instance[key].slice(0, size) | |
for (const item of instance[key]) reset(item) | |
} else if (instance[key] instanceof Set) { | |
instance[key] = new Set(Array.from(instance[key].values()).slice(0, size)) | |
for (const item of instance[key]) reset(item) | |
} else if (instance[key] instanceof Map) { | |
const keysToRemove = Array.from(instance[key].keys()).slice(size) | |
for (const key of keysToRemove) instance[key].delete(key) | |
for (const [_, value] of instance[key]) reset(value) | |
} else { | |
reset(instance[key]) | |
} | |
} | |
if (options?.errorFieldName) instance[options.errorFieldName] = options.format === 'object' ? {} : [] | |
touchedFields?.clear() | |
} | |
function errorsToObject(errors: FormErrors | undefined, multiple: boolean): FormObjectErrors { | |
if (errors === undefined) return {} | |
const result: FormObjectErrors = {} | |
const isArray = typeof errors[0]?.property === 'number' | |
if (isArray) { | |
const arrayResult: any[] = [] | |
for (const error of errors) { | |
const index = error.property as number | |
if (Array.isArray(error.children)) { | |
arrayResult[index] = errorsToObject(error.children, multiple) | |
} | |
} | |
return arrayResult | |
} | |
for (const error of errors) { | |
if (Array.isArray(error.children)) { | |
result[error.property] = errorsToObject(error.children, multiple) | |
} else if (error.errors) { | |
result[error.property] = multiple ? error.errors : error.errors[0] | |
} | |
} | |
return result | |
} | |
type FieldError = { | |
property: string | symbol | number | |
errors?: string[] | |
children?: FieldError[] | |
} | |
type Check<T = any> = (value: any, fields: T) => string | undefined | |
class Validator<Value = any, Fields = any> { | |
private property: string | symbol | |
private checks: Check[] = [] | |
private isRequired = true | |
private isCondition = false | |
public when: 'change' | 'submit' = 'submit' | |
public dependencies: Set<string | symbol> = new Set() | |
public dependent: Set<string | symbol> = new Set() | |
public isNested = false | |
constructor(property: string | symbol) { | |
this.property = property | |
} | |
optional(): this { | |
this.isRequired = false | |
return this | |
} | |
trigger(when: 'change' | 'submit', deps?: string[]): this { | |
this.when = when | |
if (deps !== undefined) { | |
for (const dep of deps) { | |
this.dependencies.add(dep) | |
} | |
} | |
return this | |
} | |
nested(): this { | |
this.isNested = true | |
return this | |
} | |
string(message = 'Value must be a string'): this { | |
this.checks.push((value) => (typeof value === 'string' ? undefined : message)) | |
return this | |
} | |
number(message = 'Value must be a number'): this { | |
this.checks.push((value) => (typeof value === 'number' ? undefined : message)) | |
return this | |
} | |
array(message = 'Value must be an array'): this { | |
this.checks.push((value) => (Array.isArray(value) ? undefined : message)) | |
return this | |
} | |
object(message = 'Value must be an object'): this { | |
this.checks.push((value) => (typeof value === 'object' && value !== null ? undefined : message)) | |
return this | |
} | |
moreThan(limit: number, message?: string): this { | |
this.checks.push((value) => { | |
if (typeof value === 'number') { | |
return value > limit ? undefined : (message ?? `Value must be greater than ${limit}`) | |
} | |
return undefined | |
}) | |
return this | |
} | |
lessThan(limit: number, message?: string): this { | |
this.checks.push((value) => { | |
if (typeof value === 'number') { | |
return value < limit ? undefined : (message ?? `Value must be less than ${limit}`) | |
} | |
return undefined | |
}) | |
return this | |
} | |
lessOrEqualThanField(field: keyof Fields, message?: string): this { | |
this.dependent.add(field as string | symbol) | |
this.checks.push((value, fields) => { | |
const fieldValue = fields[field] | |
if (typeof value === 'number' && typeof fieldValue === 'number') { | |
return value <= fieldValue ? undefined : (message ?? `Value must be less or equal than ${fieldValue}`) | |
} | |
return undefined | |
}) | |
return this | |
} | |
min(limit: number, message?: string): this { | |
this.checks.push((value) => { | |
if (typeof value === 'number') { | |
return value >= limit ? undefined : (message ?? `Value must be at least ${limit}`) | |
} | |
if (typeof value === 'string' || Array.isArray(value)) { | |
return value.length >= limit ? undefined : (message ?? `Length must be at least ${limit}`) | |
} | |
if (value instanceof Set) { | |
return value.size >= limit ? undefined : (message ?? `Size must be at least ${limit}`) | |
} | |
return undefined | |
}) | |
return this | |
} | |
max(limit: number, message?: string): this { | |
this.checks.push((value) => { | |
if (typeof value === 'number') { | |
return value <= limit ? undefined : (message ?? `Value must be at most ${limit}`) | |
} | |
if (typeof value === 'string' || Array.isArray(value)) { | |
return value.length <= limit ? undefined : (message ?? `Length must be at most ${limit}`) | |
} | |
if (value instanceof Set) { | |
return value.size <= limit ? undefined : (message ?? `Size must be at most ${limit}`) | |
} | |
return undefined | |
}) | |
return this | |
} | |
oneOf(values: any[], message?: string): this { | |
this.checks.push((value) => { | |
if (values.includes(value)) return undefined | |
return message ?? `Value must be one of ${values.join(', ')}` | |
}) | |
return this | |
} | |
if( | |
test: (fields: Fields, value: Value) => boolean, | |
consequent: (v: Validator<Value, Fields>) => void, | |
alternate?: (v: Validator<Value, Fields>) => void, | |
): this { | |
this.isCondition = true | |
const originalChecks = this.checks | |
const originalNested = this.isNested | |
const originalRequired = this.isRequired | |
this.checks = [] | |
consequent(this) | |
const thenChecks = this.checks | |
const thenNested = this.isNested | |
const thenRequired = this.isRequired | |
let elseChecks: Check[] = [] | |
let elseRequired = (this.isRequired = originalRequired) | |
let elseNested = (this.isNested = originalNested) | |
if (alternate !== undefined) { | |
this.checks = [] | |
alternate(this) | |
elseChecks = this.checks | |
elseRequired = this.isRequired | |
elseNested = this.isNested | |
} | |
this.checks = originalChecks | |
this.isRequired = originalRequired | |
this.isNested = originalNested | |
this.checks.push((value, fields) => { | |
let checks: Check[] = [] | |
let required = originalRequired | |
this.isNested = originalNested | |
if (test(fields, value)) { | |
checks = thenChecks | |
required = thenRequired | |
this.isNested = thenNested | |
} else { | |
checks = elseChecks | |
required = elseRequired | |
this.isNested = elseNested | |
} | |
if ([null, undefined].includes(value) && checks.length > 0) { | |
if (required) return 'Required' | |
return undefined | |
} | |
for (const check of checks) { | |
const error = check(value, fields) | |
if (error !== undefined) return error | |
} | |
return undefined | |
}) | |
return this | |
} | |
custom(fn: (value: Value, fields: Fields) => string | undefined): this { | |
this.checks.push(fn) | |
return this | |
} | |
validate(value: any, fields: Fields): FieldError | undefined { | |
if ([null, undefined].includes(value) && this.isCondition === false) { | |
if (this.isRequired) return { property: this.property, errors: ['Required'] } | |
return undefined | |
} | |
const fieldError: FieldError = { | |
property: this.property, | |
} | |
for (const check of this.checks) { | |
const error = check(value, fields) | |
if (error === undefined) continue | |
;(fieldError.errors ??= []).push(error) | |
} | |
return fieldError.errors && fieldError | |
} | |
} | |
export { Field, validate, reset } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment