Signed-off-by: Avior <git@avior.me>
This commit is contained in:
Florian Bouillon 2025-03-25 13:54:46 +01:00
parent 4ab90d8476
commit 817b7d6774
Signed by: Florian Bouillon
GPG Key ID: 7676FF78F3BC40EC
25 changed files with 3490 additions and 346 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
node_modules/
dist

165
Schema.ts
View File

@ -1,165 +0,0 @@
import SchemaArray from "./items/array"
import SchemaBoolean from "./items/boolean"
import SchemaEnum, { EnumLike } from "./items/enum"
import SchemaLiteral from "./items/literal"
import SchemaNullable from "./items/nullable"
import SchemaNumber from "./items/number"
import SchemaObject from "./items/object"
import SchemaString from "./items/string"
import { SchemaUnion } from "./items/union"
import SchemaItem from "./SchemaItem"
import { SchemaJSON } from "./types"
export function parceable() {
return (target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<any>) => {
if (!(target instanceof SchemaItem)) {
throw new Error('the decorator is only usable on Schema')
}
const original = target[propertyKey]
const t = function () { }
descriptor.value = function (this: SchemaItem, ...args: Array<any>) {
this.savedCalls.push({ name: propertyKey as string, args: args })
const res = original.call(this, ...args)
return res
}
return descriptor
}
}
interface SchemaItemStatic {
new(...args: Array<any>): SchemaItem
}
type ExtractGeneric<T> = T extends SchemaItem<infer U> ? U : never
export default class Schema {
private static registeredModules: Array<SchemaItemStatic> = [
SchemaArray,
SchemaBoolean,
SchemaEnum,
SchemaLiteral,
SchemaNullable,
SchemaObject,
SchemaString,
SchemaUnion
]
public static register(module: SchemaItemStatic) {
this.registeredModules.push(module)
}
public static getModule(name: string) {
return this.registeredModules.find((it) => it.name === name)
}
public static array<Type extends SchemaItem>(
...inputs: ConstructorParameters<typeof SchemaArray<Type>>
): SchemaArray<Type> {
return new SchemaArray<Type>(...inputs)
}
public static boolean(
...inputs: ConstructorParameters<typeof SchemaBoolean>
): SchemaBoolean {
return new SchemaBoolean(...inputs)
}
public static enum<Type extends EnumLike>(
...inputs: ConstructorParameters<typeof SchemaEnum<Type>>
): SchemaEnum<Type> {
return new SchemaEnum<Type>(...inputs)
}
/**
*
* @param input the literal value (note: append `as const` else the typing won't work correctly)
* @returns
*/
public static literal<Type>(
input: Type
): SchemaLiteral<Type> {
return new SchemaLiteral<Type>(input)
}
public static nullable<Type extends SchemaItem>(
...inputs: ConstructorParameters<typeof SchemaNullable<Type>>
): SchemaNullable<Type> {
return new SchemaNullable<Type>(...inputs)
}
public static number(
...inputs: ConstructorParameters<typeof SchemaNumber>
): SchemaNumber {
return new SchemaNumber(...inputs)
}
public static object<Type extends Record<string, SchemaItem<any>>>(
...inputs: ConstructorParameters<typeof SchemaObject<Type>>
): SchemaObject<Type> {
return new SchemaObject<Type>(...inputs)
}
/**
* See {@link SchemaString}
*/
public static string(
...inputs: ConstructorParameters<typeof SchemaString>
): SchemaString {
return new SchemaString(...inputs)
}
public static union<Type extends Array<SchemaItem<any>>>(
...inputs: ConstructorParameters<typeof SchemaUnion<Type>>
): SchemaUnion<Type> {
return new SchemaUnion<Type>(...inputs)
}
public static fromJSON(json: SchemaJSON): SchemaItem {
// get the module
const fn = this.getModule(json.i)
// handle module not detected
if (!fn) {
throw new Error(`Schema cannot parse ${json.i}`)
}
// init the module
const item = new fn(...(json.c?.map((it) => this.isSchemaJSON(it) ? Schema.fromJSON(it) : it) ?? []))
// handle validations
for (const validation of (json.f ?? [])) {
// validation not found in item :(
if (!(validation.n in item)) {
throw new Error('validation not available in Schema Item')
}
// init the validation
(item[validation.n] as (...params: Array<unknown>) => void)(...validation.a.map((it) => Schema.isSchemaJSON(it) ? Schema.fromJSON(it) : it))
}
// add the attributes
item.attrs(...json.a ?? [])
return item
}
public static isSchemaJSON(data: unknown): data is SchemaJSON {
if (typeof data !== 'object' || data === null) {
return false
}
if (!('i' in data)) {
return false
}
return true
}
}

BIN
bun.lockb

Binary file not shown.

View File

@ -8,11 +8,24 @@ export default [
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
ignores: [
'tests',
'eslint.config.mjs',
'dist',
'index.ts'
]
},
{
languageOptions: {
parserOptions: {
project: ['tsconfig.json']
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
languageOptions: {
globals: {
...globals.node,
...globals.browser,
@ -23,16 +36,6 @@ export default [
'@typescript-eslint': typescriptEslint,
'@stylistic': stylistic
},
ignores: [
'node_modules/',
'out/',
'*.js',
'__tests__/',
'src/route.ts',
'dist/',
'.astro/',
'.diaz/'
],
rules: {
'@stylistic/arrow-parens': [

View File

@ -1,10 +1,12 @@
import s from "./Schema"
import { Infer, s } from '.'
s.string()
const res = new s({
a: s.object({
p: s.string()
})
})
type Infered = Infer<typeof res>
/*
buffer
date
file
record
*/
type Test = Infered['a']['p']
console.log(res.parse("{ hello: 'world' }"))

2805
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,43 @@
{
"dependencies": {
"@dzeio/object-util": "^1.8.3",
"@eslint/js": "^9.18.0",
"@stylistic/eslint-plugin": "^2.13.0",
"@types/bun": "^1.1.18",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"globals": "^15.14.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.21.0",
"vitest": "^3.0.4"
}
"name": "@dzeio/schema",
"version": "0.0.2",
"dependencies": {
"@dzeio/object-util": "^1.8.3"
},
"main": "./dist/Schema.js",
"module": "./dist/Schema.mjs",
"types": "./dist/Schema.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"require": {
"types": "./dist/Schema.d.ts",
"default": "./dist/Schema.js"
},
"import": {
"types": "./dist/Schema.d.mts",
"default": "./dist/Schema.mjs"
}
}
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@standard-schema/spec": "^1.0.0",
"@stylistic/eslint-plugin": "^2.13.0",
"@types/bun": "^1.1.18",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"globals": "^15.14.0",
"tsup": "^8.3.6",
"typescript": "^5.7.3",
"typescript-eslint": "^8.21.0",
"vitest": "^3.0.4"
},
"scripts": {
"test": "bun test",
"lint": "eslint",
"build": "rm -rf dist && tsup ./src/Schema.ts --format cjs,esm --dts --clean",
"prepublishOnly": "rm -rf dist && tsup ./src/Schema.ts --format cjs,esm --dts --clean"
}
}

241
src/Schema.ts Normal file
View File

@ -0,0 +1,241 @@
/* eslint-disable id-blacklist */
import { parseForm, parseFormData, parseQuery } from 'helpers'
import SchemaDate from 'items/date'
import SchemaRecord from 'items/record'
import SchemaArray from './items/array'
import SchemaBoolean from './items/boolean'
import SchemaEnum, { EnumLike } from './items/enum'
import SchemaLiteral from './items/literal'
import SchemaNullable from './items/nullable'
import SchemaNumber from './items/number'
import SchemaObject from './items/object'
import SchemaString from './items/string'
import SchemaUnion from './items/union'
import SchemaItem from './SchemaItem'
import { SchemaJSON } from './types'
export function parceable() {
return (target: object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<any>) => {
// make sure the target is of SchemaItem
if (!(target instanceof SchemaItem)) {
throw new Error('the decorator is only usable on Schema')
}
// make sur the property exists in the target
if (!(propertyKey in target)) {
throw new Error('property not set in object')
}
// @ts-expect-error call a function defined from calls of collable
const original = target[propertyKey] as (...args: Array<unknown>) => unknown
// replace original function with modified one
descriptor.value = function(this: SchemaItem, ...args: Array<unknown>) {
this.savedCalls.push({ name: propertyKey as string, args: args as Array<string> })
const res: unknown = original.call(this, ...args)
return res
}
// return the modified descriptor
return descriptor
}
}
type SchemaItemStatic = new (...args: Array<any>) => SchemaItem
export const Types = {
Array: SchemaArray,
Boolean: SchemaBoolean,
Date: SchemaDate,
Enum: SchemaEnum,
Literal: SchemaLiteral,
Nullable: SchemaNullable,
Object: SchemaObject,
Record: SchemaRecord,
String: SchemaString,
Union: SchemaUnion
} as const
export default class Schema<T extends Record<string, SchemaItem> = Record<string, SchemaItem>> extends SchemaObject<T> {
private static registeredModules: Array<SchemaItemStatic> = [
SchemaArray,
SchemaBoolean,
SchemaDate,
SchemaEnum,
SchemaLiteral,
SchemaNullable,
SchemaObject,
SchemaRecord,
SchemaString,
SchemaUnion
]
public static register(module: SchemaItemStatic) {
this.registeredModules.push(module)
}
public static getModule(name: string) {
return this.registeredModules.find((it) => it.name === name)
}
public static array<Type extends SchemaItem>(
...inputs: ConstructorParameters<typeof SchemaArray<Type>>
): SchemaArray<Type> {
return new SchemaArray<Type>(...inputs)
}
public static boolean(
...inputs: ConstructorParameters<typeof SchemaBoolean>
): SchemaBoolean {
return new SchemaBoolean(...inputs)
}
public static date(
...inputs: ConstructorParameters<typeof SchemaDate>
): SchemaDate {
return new SchemaDate(...inputs)
}
public static enum<Type extends EnumLike>(
...inputs: ConstructorParameters<typeof SchemaEnum<Type>>
): SchemaEnum<Type> {
return new SchemaEnum<Type>(...inputs)
}
/**
*
* @param input the literal value (note: append `as const` else the typing won't work correctly)
* @returns
*/
public static literal<Type>(
input: Type
): SchemaLiteral<Type> {
return new SchemaLiteral<Type>(input)
}
public static nullable<Type extends SchemaItem>(
...inputs: ConstructorParameters<typeof SchemaNullable<Type>>
): SchemaNullable<Type> {
return new SchemaNullable<Type>(...inputs)
}
public static number(
...inputs: ConstructorParameters<typeof SchemaNumber>
): SchemaNumber {
return new SchemaNumber(...inputs)
}
public static object<Type extends Record<string, SchemaItem>>(
...inputs: ConstructorParameters<typeof SchemaObject<Type>>
): SchemaObject<Type> {
return new SchemaObject<Type>(...inputs)
}
public static record<Keys extends SchemaItem, Values extends SchemaItem>(
...inputs: ConstructorParameters<typeof SchemaRecord<Keys, Values>>
): SchemaRecord<Keys, Values> {
return new SchemaRecord<Keys, Values>(...inputs)
}
/**
* See {@link SchemaString}
*/
public static string(
...inputs: ConstructorParameters<typeof SchemaString>
): SchemaString {
return new SchemaString(...inputs)
}
public static union<Type extends Array<SchemaItem>>(
...inputs: ConstructorParameters<typeof SchemaUnion<Type>>
): SchemaUnion<Type> {
return new SchemaUnion<Type>(...inputs)
}
public static fromJSON(json: SchemaJSON): SchemaItem {
// get the module
const fn = this.getModule(json.i)
// handle module not detected
if (!fn) {
throw new Error(`Schema cannot parse ${json.i}`)
}
// init the module
const item = new fn(...(json.c?.map((it) => this.isSchemaJSON(it) ? Schema.fromJSON(it) : it) ?? []))
// handle validations
for (const validation of (json.f ?? [])) {
// validation not found in item :(
if (!(validation.n in item)) {
throw new Error('validation not available in Schema Item')
}
// init the validation
// @ts-expect-error call a function defined from calls of collable
(item[validation.n] as (...params: Array<unknown>) => void)(...validation.a?.map((it) => Schema.isSchemaJSON(it) ? Schema.fromJSON(it) : it) ?? [])
}
// add the attributes
item.attrs(...json.a ?? [])
return item
}
public static isSchemaJSON(data: unknown): data is SchemaJSON {
if (typeof data !== 'object' || data === null) {
return false
}
if (!('i' in data)) {
return false
}
return true
}
/**
* @deprecated use helper `parseQuery`
*/
public validateQuery(query: URLSearchParams, fast = false) {
return parseQuery(this, query, { fast })
}
/**
* @deprecated use `parse`
*/
public validate(input: unknown, fast = false) {
return this.parse(input, { fast })
}
/**
* @deprecated use helper `parseForm`
*/
public validateForm(form: HTMLFormElement, fast = false) {
return parseForm(this, form, { fast })
}
/**
* @deprecated use helper `parseFormData`
*/
public validateFormData(data: FormData, fast = false) {
return parseFormData(this, data, { fast })
}
}
export const s = Schema
export * from './helpers'
export type * from './types.d.ts'
export {
SchemaArray,
SchemaBoolean, SchemaDate, SchemaEnum, SchemaItem, SchemaLiteral,
SchemaNullable,
SchemaNumber,
SchemaObject, SchemaRecord, SchemaString,
SchemaUnion
}

View File

@ -1,37 +1,41 @@
import Schema from "./Schema"
import { SchemaJSON, ValidationError, ValidationResult } from "./types"
/* eslint-disable id-length */
import { objectClean } from '@dzeio/object-util'
import { StandardSchemaV1 } from '@standard-schema/spec'
import Schema, { SchemaNullable } from './Schema'
import { SchemaJSON, ValidationError, ValidationResult } from './types'
export default abstract class SchemaItem<Type = any> implements StandardSchemaV1<Type> {
export default abstract class SchemaItem<Type = any> {
private invalidError = 'the field is invalid'
/**
* list of attributes for custom works
*/
public readonly attributes: Array<string> = []
public attrs(...attributes: Array<string>) {
this.attributes.concat(attributes)
return this
}
public setInvalidError(err: string): this {
this.invalidError = err
return this
}
public clone(): this {
return Schema.fromJSON(this.toJSON()) as this
// standard Schema V1 spec
public '~standard': StandardSchemaV1.Props<Type> = {
vendor: 'aptatio',
version: 1,
validate: (value: unknown) => {
const res = this.parse(value)
if (!res.valid) {
return {
issues: res.errors
}
}
return {
value: res.object,
issues: res.errors,
}
}
}
/**
* schemas implementing unwrap can return their child component (mostly the value)
*
* ex: s.record(s.number(), s.string()) returns s.string()
*
* es2: s.nullable(s.string()) returns s.string()
* keep public ?
*/
public unwrap?(): SchemaItem
public validations: Array<{
fn: (input: Type) => boolean
error?: string | undefined
}> = []
/**
* Function calls saved for serialization
*/
public savedCalls: Array<{ name: string, args: Array<string> | IArguments | undefined }> = []
/**
* Pre process the variable for various reasons
@ -53,23 +57,51 @@ export default abstract class SchemaItem<Type = any> {
public postProcess: Array<(input: Type) => Type> = []
/**
* keep public ?
* list of attributes for custom works
*/
public validations: Array<{
fn: (input: Type) => boolean
error?: string | undefined
}> = []
public savedCalls: Array<{ name: string, args: Array<string> | IArguments | undefined }> = []
public readonly attributes: Array<string> = []
private readonly items?: Array<unknown>
private invalidError = 'the field is invalid'
public constructor(items?: Array<unknown> | IArguments) {
if (items && items.length > 0) {
this.items = Array.isArray(items) ? items : Array.from(items)
}
}
public attrs(...attributes: Array<string>) {
this.attributes.concat(attributes)
return this
}
public attr(...attributes: Array<string>) {
return this.attrs(...attributes)
}
public setInvalidError(err: string): this {
this.invalidError = err
return this
}
public clone(): this {
return Schema.fromJSON(this.toJSON()) as this
}
public nullable(): SchemaNullable<this> {
return new SchemaNullable(this)
}
/**
* schemas implementing unwrap can return their child component (mostly the value)
*
* ex: s.record(s.number(), s.string()) returns s.string()
*
* es2: s.nullable(s.string()) returns s.string()
*/
public unwrap?(): SchemaItem
public parse(input: unknown, options?: { fast?: boolean }): ValidationResult<Type> {
// pre process the variable
for (const preProcess of this.preProcess) {
@ -95,6 +127,7 @@ export default abstract class SchemaItem<Type = any> {
})
// if the system should be fast, stop checking for other errors
// eslint-disable-next-line max-depth
if (options?.fast) {
return {
valid: false,
@ -119,26 +152,27 @@ export default abstract class SchemaItem<Type = any> {
input = postProcess(input as Type)
}
// validate that the pre process handled correctly the variable
if (!this.isOfType(input)) {
throw new Error('Post process error occured :(')
}
return {
valid: true,
object: input
object: input as Type
}
}
public toJSON(): SchemaJSON {
return {
// build the JSON
const res = {
i: this.constructor.name,
a: this.attributes.length > 0 ? this.attributes : undefined,
c: this.items?.map((it) => it instanceof SchemaItem ? it.toJSON() : it),
f: this.savedCalls
.map((it) => (it.args ? { n: it.name, a: Array.from(it.args) } : { n: it.name }))
}
// cleanup the object from undefined to make it smaller
objectClean(res, { deep: false })
return res
}
protected addValidation(fn: ((input: Type) => boolean) | { fn: (input: Type) => boolean, error?: string }, error?: string) {
@ -148,6 +182,7 @@ export default abstract class SchemaItem<Type = any> {
return this
}
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
protected addPreProcess(fn: (input: unknown) => Type | unknown) {
this.preProcess.push(fn)

60
src/helpers.ts Normal file
View File

@ -0,0 +1,60 @@
import { objectGet, objectLoop, objectSet } from '@dzeio/object-util'
import SchemaArray from 'items/array'
import SchemaBoolean from 'items/boolean'
import SchemaNullable from 'items/nullable'
import SchemaObject from 'items/object'
import type SchemaItem from 'SchemaItem'
export function parseQuery<T extends SchemaItem>(model: T, query: URLSearchParams, opts?: Parameters<T['parse']>[1]): ReturnType<T['parse']> {
const record: Record<string, unknown> = {}
for (const [key, value] of query) {
record[key] = value
}
return model.parse(record, opts) as ReturnType<T['parse']>
}
export function parseFormData<T extends SchemaObject>(model: T, data: FormData, opts?: Parameters<T['parse']>[1]): ReturnType<T['parse']> {
const record: Record<string, unknown> = {}
// console.log('VALIDATE FORM DATA data', data)
for (const [key, value] of data) {
// console.log('parse', key, value)
const isArray = model.model[key].isOfType([]) ?? false
// record[key] = isArray ? data.getAll(key) : value
objectSet(record, key.split('.').map((it) => /^\d+$/g.test(it) ? parseInt(it, 10) : it), isArray ? data.getAll(key) : value)
}
// quick hack to handle FormData not returning Checkboxes
const handleBoolean = (value: SchemaItem, keys: Array<string | number>) => {
if (value instanceof SchemaNullable) {
handleBoolean(value.unwrap(), keys)
}
if (value instanceof SchemaArray) {
const elements: Array<unknown> | undefined = objectGet(record, keys)
for (let it = 0; it < (elements?.length ?? 0); it++) {
handleBoolean(value.unwrap(), [...keys, it])
}
}
if (value instanceof SchemaObject) {
handleSchemaForBoolean(value.model as Record<string, SchemaItem>, keys)
}
if (value instanceof SchemaBoolean) {
objectSet(record, keys, !!data.get(keys.join('.')))
}
}
const handleSchemaForBoolean = (modl: Record<string, SchemaItem>, keys: Array<string | number> = []) => {
objectLoop(modl, (value, key) => {
handleBoolean(value as unknown as SchemaItem, [...keys, key])
})
}
handleSchemaForBoolean(model.model)
// console.log(JSON.stringify(record, undefined, 2))
return model.parse(record, opts) as ReturnType<T['parse']>
}
export function parseForm<T extends SchemaObject>(model: T, form: HTMLFormElement, opts?: Parameters<T['parse']>[1]): ReturnType<T['parse']> {
return parseFormData(model, new FormData(form), opts)
}

View File

@ -1,13 +1,9 @@
import { parceable } from "../Schema"
import SchemaItem from "../SchemaItem"
import { SchemaInfer, ValidationError, ValidationResult } from "../types"
import { parceable } from '../Schema'
import SchemaItem from '../SchemaItem'
import { SchemaInfer, ValidationError, ValidationResult } from '../types'
export default class SchemaArray<Type extends SchemaItem> extends SchemaItem<Array<SchemaInfer<Type>>> {
public constructor(public readonly values: Type) { super(arguments) }
public unwrap() {
return this.values
}
public constructor(public readonly values: Type) { super([values]) }
/**
* transform the array so it only contains one of each elements
@ -22,7 +18,7 @@ export default class SchemaArray<Type extends SchemaItem> extends SchemaItem<Arr
public parse(input: unknown, options?: { fast?: boolean }): ValidationResult<Array<SchemaInfer<Type>>> {
// check errors from itself
let { valid, object, errors = [] } = super.parse(input, options)
const { valid, object, errors = [] } = super.parse(input, options)
// skip checking childs if self is not valid (maybe still try to check childs whan fast is false ?)
if (!valid) {
@ -37,39 +33,49 @@ export default class SchemaArray<Type extends SchemaItem> extends SchemaItem<Arr
const errs: Array<ValidationError> = []
for (let idx = 0; idx < (object as Array<Type>).length; idx++) {
const item = (object as Array<Type>)[idx]
const res = this.values.parse(item)
if (res.errors && res.errors.length > 0) {
const errors = res.errors.map((it) => ({
...it,
field: it.field ? `${idx}.${it.field}` : idx.toString()
}))
if (options?.fast) {
return {
valid: false,
object: clone as any,
errors: errors
}
}
errs.push(...errors)
} else {
const res: ValidationResult<SchemaInfer<Type>> = this.values.parse(item)
// handle valid child schema
if (res.valid) {
clone.push(res.object)
continue
}
// handle child errors
const errss = res.errors.map((it) => ({
...it,
field: it.field ? `${idx}.${it.field}` : idx.toString()
}))
if (options?.fast) {
return {
valid: false,
object: clone,
errors: errss
}
}
errs.push(...errss)
}
if (errs.length > 0) {
return {
valid: false,
object: clone as any,
object: clone,
errors: errs
}
}
return {
valid: true,
object: clone as any
object: clone
}
}
public override unwrap(): Type {
return this.values
}
public override isOfType(input: unknown): input is Array<SchemaInfer<Type>> {
return Array.isArray(input)
}

View File

@ -1,5 +1,5 @@
import { parceable } from "../Schema"
import SchemaItem from "../SchemaItem"
import { parceable } from '../Schema'
import SchemaItem from '../SchemaItem'
export default class SchemaBoolean extends SchemaItem<boolean> {

8
src/items/date.ts Normal file
View File

@ -0,0 +1,8 @@
import SchemaItem from '../SchemaItem'
export default class SchemaDate extends SchemaItem<Date> {
public override isOfType(input: unknown): input is Date {
return input instanceof Date
}
}

View File

@ -1,6 +1,6 @@
import SchemaItem from '../SchemaItem'
export type EnumLike = {
export interface EnumLike {
[k: string]: string | number
[n: number]: string
}
@ -21,7 +21,7 @@ export default class SchemaEnum<E extends EnumLike> extends SchemaItem<E[keyof E
// test above === numebr
this.validations.push({
fn: (input) => Object.values(this.templateEnum).includes(input),
message: `Input is not part of ${templateEnum.constructor.name}`
error: `Input is not part of ${templateEnum.constructor.name}`
})
}

View File

@ -1,10 +1,10 @@
import SchemaItem from "../SchemaItem"
import SchemaItem from '../SchemaItem'
export default class SchemaLiteral<Type> extends SchemaItem<Type> {
public constructor(private readonly value: Type) {
super(arguments)
super([value])
this.validations.push({ fn: (value) => value === this.value })
this.validations.push({ fn: (it) => it === this.value })
}
public override isOfType(input: unknown): input is Type {

View File

@ -1,14 +1,14 @@
import SchemaItem from "../SchemaItem"
import { SchemaInfer, ValidationResult } from "../types"
import SchemaItem from '../SchemaItem'
import { SchemaInfer, ValidationResult } from '../types'
export default class SchemaNullable<Type extends SchemaItem> extends SchemaItem<SchemaInfer<Type> | undefined> {
public constructor(public readonly child: Type) { super(arguments) }
public constructor(public readonly child: Type) { super([child]) }
public unwrap() {
public unwrap(): Type {
return this.child
}
public parse(input: unknown, options?: { fast?: boolean }): ValidationResult<SchemaInfer<Type>> {
public override parse(input: unknown, options?: { fast?: boolean }): ValidationResult<SchemaInfer<Type> | undefined> {
if (this.isNull(input)) {
return {
valid: true,
@ -16,7 +16,7 @@ export default class SchemaNullable<Type extends SchemaItem> extends SchemaItem<
}
}
return this.child.parse(input)
return this.child.parse(input, options)
}
public override isOfType(input: unknown): input is SchemaInfer<Type> | undefined {

View File

@ -3,14 +3,6 @@ import SchemaItem from '../SchemaItem'
export default class SchemaNumber extends SchemaItem<number> {
public min(...params: Parameters<SchemaNumber['gte']>): this {
return this.gte(...params)
}
public max(...params: Parameters<SchemaNumber['lte']>): this {
return this.lte(...params)
}
/**
* validate that the number is less or equal than {@link value}
* @param value the maxumum value (inclusive)
@ -92,4 +84,12 @@ export default class SchemaNumber extends SchemaItem<number> {
public override isOfType(input: unknown): input is number {
return typeof input === 'number' && !Number.isNaN(input)
}
public min(...params: Parameters<SchemaNumber['gte']>): this {
return this.gte(...params)
}
public max(...params: Parameters<SchemaNumber['lte']>): this {
return this.lte(...params)
}
}

View File

@ -1,21 +1,21 @@
import { isObject, objectLoop, objectClone } from '@dzeio/object-util'
import SchemaItem from "../SchemaItem"
import { SchemaInfer, ValidationResult } from "../types"
import { isObject, objectClone, objectLoop } from '@dzeio/object-util'
import SchemaItem from '../SchemaItem'
import { SchemaInfer, ValidationResult } from '../types'
type ModelInfer<M extends Record<string, SchemaItem<any>>> = {
type ModelInfer<M extends Record<string, SchemaItem>> = {
[key in keyof M]: SchemaInfer<M[key]>
}
export default class SchemaObject<T extends Record<string, SchemaItem<any>>> extends SchemaItem<ModelInfer<T>> {
public id: string = 'object'
export default class SchemaObject<T extends Record<string, SchemaItem> = Record<string, SchemaItem>> extends SchemaItem<ModelInfer<T>> {
public id = 'object'
public constructor(public readonly model: T) {
super(arguments)
super([model])
}
public override parse(input: unknown, options?: { fast?: boolean }): ValidationResult<ModelInfer<T>> {
// check errors from itself
let { valid, object, errors = [] } = super.parse(objectClone(input), options)
const { valid, object, errors = [] } = super.parse(input, options)
// skip checking childs if self is not valid (maybe still try to check childs whan fast is false ?)
if (!valid) {
@ -26,20 +26,26 @@ export default class SchemaObject<T extends Record<string, SchemaItem<any>>> ext
} as ValidationResult<ModelInfer<T>>
}
const clone = objectClone(object)
// loop through the childs
objectLoop(this.model, (childSchema, key) => {
const childValue = object![key]
const childValue = clone[key]
// parse the child
const child = childSchema.parse(childValue)
// add errors
if ((child.errors?.length ?? 0) > 0) {
errors.push(...child.errors!)
if (!child.valid) {
errors.push(...child.errors.map((it) => ({
...it,
field: it.field ? `${key}.${it.field}` : key
})))
}
// @ts-expect-error while it's a generic we know by proof above that it's valid !
object![key] = child.object
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
clone[key] = child.object
// skip rest of items if current one is invalid
return child.valid || !options?.fast
@ -49,7 +55,7 @@ export default class SchemaObject<T extends Record<string, SchemaItem<any>>> ext
return {
valid: errors.length === 0,
errors: errors,
object: object
object: clone
} as ValidationResult<ModelInfer<T>>
}

55
src/items/record.ts Normal file
View File

@ -0,0 +1,55 @@
import { isObject, objectLoop } from '@dzeio/object-util'
import SchemaItem from '../SchemaItem'
import { SchemaInfer, ValidationResult } from '../types'
export default class SchemaRecord<Keys extends SchemaItem, Values extends SchemaItem> extends SchemaItem<Record<SchemaInfer<Keys>, SchemaInfer<Values>>> {
public constructor(private readonly keys: Keys, private readonly values: Values) {
super([keys, values])
}
public parse(input: unknown, options?: { fast?: boolean }): ValidationResult<Record<SchemaInfer<Keys>, SchemaInfer<Values>>> {
// check errors from itself
const { valid, object, errors = [] } = super.parse(input, options)
// skip checking childs if self is not valid (maybe still try to check childs whan fast is false ?)
if (!valid) {
return {
valid,
object,
errors
} as ValidationResult<SchemaInfer<this>>
}
const clone: Partial<SchemaInfer<this>> = {}
objectLoop(object, (value, key: string | number) => {
const res1 = this.keys.parse(key)
const res2 = this.values.parse(value)
if (!res1.valid || !res2.valid) {
errors.push(...((res1.errors ?? []).concat(...(res2.errors ?? []))).map((it) => ({
message: it.message,
field: it.field ? `${key as string}.${it.field}` : key.toString()
})))
} else {
// @ts-expect-error normal behavior
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
clone[res1.object] = res2.object
}
// skip completion if fast is enabled
return errors.length === 0 || !options?.fast
})
// answer !
return {
valid: errors.length === 0,
errors: errors,
object: clone
} as ValidationResult<SchemaInfer<this>>
}
public override isOfType(input: unknown): input is Record<SchemaInfer<Keys>, SchemaInfer<Values>> {
return isObject(input) && Object.prototype.toString.call(input) === '[object Object]'
}
}

View File

@ -1,16 +1,7 @@
import { parceable } from "../Schema"
import SchemaItem from "../SchemaItem"
import { parceable } from '../Schema'
import SchemaItem from '../SchemaItem'
export default class SchemaString extends SchemaItem<string> {
public id = 'string'
public minLength(value: number, message?: string) {
return this.min(value, message)
}
public maxLength(value: number, message?: string) {
return this.max(value, message)
}
/**
* force the input text to be a minimum of `value` size
@ -89,6 +80,13 @@ export default class SchemaString extends SchemaItem<string> {
return this
}
public minLength(value: number, message?: string) {
return this.min(value, message)
}
public maxLength(value: number, message?: string) {
return this.max(value, message)
}
public override isOfType(input: unknown): input is string {
return typeof input === 'string'

View File

@ -1,8 +1,8 @@
import SchemaItem from '../SchemaItem'
import { SchemaInfer, ValidationResult } from '../types'
type ItemType<T extends Array<SchemaItem<any>>> = SchemaInfer<T[number]>
export class SchemaUnion<T extends Array<SchemaItem<any>>> extends SchemaItem<ItemType<T>> {
type ItemType<T extends Array<SchemaItem>> = SchemaInfer<T[number]>
export default class SchemaUnion<T extends Array<SchemaItem>> extends SchemaItem<ItemType<T>> {
private schemas: T
@ -14,7 +14,7 @@ export class SchemaUnion<T extends Array<SchemaItem<any>>> extends SchemaItem<It
public parse(input: unknown, options?: { fast?: boolean }): ValidationResult<SchemaInfer<T[number]>> {
// check errors from itself
let { valid, object, errors = [] } = super.parse(input, options)
const { valid, object, errors = [] } = super.parse(input, options)
// skip checking childs if self is not valid (maybe still try to check childs whan fast is false ?)
if (!valid) {

57
src/types.d.ts vendored Normal file
View File

@ -0,0 +1,57 @@
import SchemaItem from './SchemaItem'
export type SchemaInfer<Type extends SchemaItem> = Type extends SchemaItem<infer X> ? X : never
export type Infer<Type extends SchemaItem> = SchemaInfer<Type>
export interface ValidationError {
message: string
field?: string
value?: unknown
}
export type ValidationResult<T> = {
object: T
valid: true
errors?: undefined
} | {
valid: false
object?: (T extends object ? Partial<T> : T) | undefined
errors: Array<ValidationError>
}
export type ValidationResultOld<T> = {
object: T
valid: true
error?: undefined
} | {
valid: false
object?: (T extends object ? Partial<T> : T) | undefined
error: Array<ValidationError>
}
export interface SchemaJSON {
i: string
a?: Array<string> | undefined
c?: Array<unknown> | undefined
f?: Array<{
n: string
a?: Array<unknown> | undefined
}> | undefined
}
/**
* @deprecated use `SchemaJSON`
*/
export type SchemaItemJSON = SchemaJSON
/**
* @deprecated use `Record<string, SchemaItem>`
*/
export type Model = Record<string, SchemaItem>
/**
* @deprecated
*/
export type ModelInfer<M extends Model> = {
[key in keyof M]: SchemaInfer<M[key]>
}

View File

@ -1,5 +1,5 @@
import { expect, test } from 'bun:test'
import s from '../Schema'
import s from '../src/Schema'
test('number enum', () => {
enum Test {
@ -74,6 +74,13 @@ test('object', () => {
expect(schema.parse({ a: 'a', b: '1' }).valid).toBe(false)
})
test('record', () => {
const schema = s.record(s.string(), s.number())
expect(schema.parse({ a: 1, b: 2 }).valid).toBe(true)
expect(schema.parse({ a: 1, b: '1' }).valid).toBe(false)
})
test('string', () => {
const schema = s.string()
expect(schema.parse('1').valid).toBe(true)

View File

@ -1,10 +1,34 @@
{
"exclude": [
"cypress"
"include": [
"src",
],
"compilerOptions": {
// Compilation
"baseUrl": "src",
"target": "ES2019", // Follow NodeJS oldest supported LTS and use version from https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping
"module": "commonjs",
"resolveJsonModule": true,
"moduleResolution": "node",
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"allowJs": true,
"pretty": true,
"allowSyntheticDefaultImports": true,
// Type Checking
"forceConsistentCasingInFileNames": true,
"alwaysStrict": true,
"strict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}

29
types.d.ts vendored
View File

@ -1,29 +0,0 @@
import SchemaItem from "./SchemaItem"
export type SchemaInfer<Type extends SchemaItem> = Type extends SchemaItem<infer X> ? X : never
export interface ValidationError {
message: string
field?: string
value?: unknown
}
export type ValidationResult<T> = {
object: T
valid: true
errors?: undefined
} | {
valid: false
object?: (T extends object ? Partial<T> : T) | undefined
errors: Array<ValidationError>
}
export interface SchemaJSON<T extends SchemaItem = any> {
i: string
a?: Array<string> | undefined
c?: Array<unknown> | undefined
f?: Array<{
n: string
a?: Array<unknown> | undefined
}> | undefined
}