Skip to content

Instantly share code, notes, and snippets.

@b2whats
Created August 4, 2025 15:46
Show Gist options
  • Save b2whats/d7d11a9f76abde43d419dda271cb22d3 to your computer and use it in GitHub Desktop.
Save b2whats/d7d11a9f76abde43d419dda271cb22d3 to your computer and use it in GitHub Desktop.
/* 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