chore: initial commit

This commit is contained in:
Florian Bouillon 2025-01-23 16:26:27 +01:00
commit 4ab90d8476
19 changed files with 1361 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

165
Schema.ts Normal file
View File

@ -0,0 +1,165 @@
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
}
}

164
SchemaItem.ts Normal file
View File

@ -0,0 +1,164 @@
import Schema from "./Schema"
import { SchemaJSON, ValidationError, ValidationResult } from "./types"
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
}
/**
* 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
/**
* Pre process the variable for various reasons
*
* It will execute every pre-process sequentially in order of being added
*
* note: The type of the final Pre-Process MUST be valid
*/
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
public preProcess: Array<(input: unknown) => Type | unknown> = []
/**
* post process the variable after it being validated
*
* it will execute each post-processes sequentially
*
* note: the type of the final post-process MUST be valid
*/
public postProcess: Array<(input: Type) => Type> = []
/**
* keep public ?
*/
public validations: Array<{
fn: (input: Type) => boolean
error?: string | undefined
}> = []
public savedCalls: Array<{ name: string, args: Array<string> | IArguments | undefined }> = []
private readonly items?: Array<unknown>
public constructor(items?: Array<unknown> | IArguments) {
if (items && items.length > 0) {
this.items = Array.isArray(items) ? items : Array.from(items)
}
}
public parse(input: unknown, options?: { fast?: boolean }): ValidationResult<Type> {
// pre process the variable
for (const preProcess of this.preProcess) {
input = preProcess(input)
}
// validate that the pre process handled correctly the variable
if (!this.isOfType(input)) {
return {
valid: false,
errors: [{ message: 'invalid type' }]
}
}
// run validation checks
const errors: Array<ValidationError> = []
for (const validation of this.validations) {
if (!validation.fn(input)) {
// add the error to the list
errors.push({
message: validation.error ?? this.invalidError
})
// if the system should be fast, stop checking for other errors
if (options?.fast) {
return {
valid: false,
object: input as (Type extends object ? Partial<Type> : Type) | undefined,
errors: errors
}
}
}
}
// return with errors
if (errors.length > 0) {
return {
valid: false,
object: input as (Type extends object ? Partial<Type> : Type) | undefined,
errors: errors
}
}
// post-process the variable
for (const postProcess of this.postProcess) {
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
}
}
public toJSON(): SchemaJSON {
return {
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 }))
}
}
protected addValidation(fn: ((input: Type) => boolean) | { fn: (input: Type) => boolean, error?: string }, error?: string) {
this.validations.push(typeof fn === 'function' ? { fn, error } : fn)
return this
}
protected addPreProcess(fn: (input: unknown) => Type | unknown) {
this.preProcess.push(fn)
return this
}
protected addPostProcess(fn: (input: Type) => Type) {
this.postProcess.push(fn)
return this
}
public abstract isOfType(input: unknown): input is Type
}

BIN
bun.lockb Executable file

Binary file not shown.

308
eslint.config.mjs Normal file
View File

@ -0,0 +1,308 @@
import js from '@eslint/js'
import stylistic from '@stylistic/eslint-plugin'
import typescriptEslint from '@typescript-eslint/eslint-plugin'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default [
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
languageOptions: {
parserOptions: {
project: ['tsconfig.json']
},
globals: {
...globals.node,
...globals.browser,
...globals.nodeBuiltin
}
},
plugins: {
'@typescript-eslint': typescriptEslint,
'@stylistic': stylistic
},
ignores: [
'node_modules/',
'out/',
'*.js',
'__tests__/',
'src/route.ts',
'dist/',
'.astro/',
'.diaz/'
],
rules: {
'@stylistic/arrow-parens': [
'error',
'always'
],
'@stylistic/eol-last': 'error',
'@stylistic/indent': [
'error',
'tab',
{
SwitchCase: 1
}
],
'@stylistic/linebreak-style': [
'error',
'unix'
],
'@stylistic/max-len': [
'warn',
{
code: 256
}
],
'@stylistic/member-delimiter-style': [
'error',
{
multiline: {
delimiter: 'none',
requireLast: true
},
singleline: {
delimiter: 'comma',
requireLast: false
}
}
],
'@stylistic/new-parens': 'error',
'@stylistic/no-extra-parens': 'off',
'@stylistic/no-extra-semi': 'error',
'@stylistic/no-multiple-empty-lines': 'error',
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/quote-props': [
'error',
'consistent-as-needed'
],
'@stylistic/quotes': [
'error',
'single',
{
avoidEscape: true
}
],
'@stylistic/semi': [
'error',
'never'
],
'@stylistic/space-before-function-paren': [
'error',
{
anonymous: 'never',
asyncArrow: 'always',
named: 'never'
}
],
'@stylistic/spaced-comment': [
'error',
'always',
{
block: {
exceptions: [
'*'
]
}
}
],
'@stylistic/type-annotation-spacing': 'error',
'@typescript-eslint/adjacent-overload-signatures': 'error',
'@typescript-eslint/array-type': [
'error',
{
default: 'generic'
}
],
'@typescript-eslint/consistent-type-assertions': 'error',
'@typescript-eslint/consistent-type-definitions': 'error',
'@typescript-eslint/explicit-member-accessibility': [
'error',
{
accessibility: 'explicit'
}
],
'@typescript-eslint/member-ordering': 'error',
'@typescript-eslint/no-empty-function': 'error',
'@typescript-eslint/no-empty-interface': 'error',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-extra-parens': 'off',
'@typescript-eslint/no-extraneous-class': [
'warn',
{
allowStaticOnly: true
}
],
'@typescript-eslint/no-misused-new': 'error',
'@typescript-eslint/no-namespace': 'error',
'@typescript-eslint/no-non-null-assertion': [
'warn'
],
'@typescript-eslint/no-parameter-properties': 'off',
'@typescript-eslint/no-shadow': 'error',
'@typescript-eslint/no-unused-expressions': [
'error',
{
allowTernary: true
}
],
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true
}
],
'@typescript-eslint/prefer-for-of': 'error',
'@typescript-eslint/prefer-function-type': 'error',
'@typescript-eslint/prefer-namespace-keyword': 'error',
'@typescript-eslint/restrict-template-expressions': [
'error',
{
allowNumber: true
}
],
'@typescript-eslint/triple-slash-reference': 'error',
'@typescript-eslint/unified-signatures': 'error',
'arrow-body-style': 'error',
'complexity': [
'warn',
10
],
'constructor-super': 'error',
'curly': 'error',
'dot-notation': 'error',
'eqeqeq': [
'error',
'smart'
],
'for-direction': 'error',
'getter-return': 'error',
'guard-for-in': 'error',
'id-blacklist': [
'error',
'any',
'Number',
'number',
'String',
'string',
'Boolean',
'boolean',
'Undefined'
],
'id-length': [
'warn',
{
exceptions: [
'_'
]
}
],
'id-match': 'error',
'max-classes-per-file': [
'error',
1
],
'max-depth': [
'warn',
2
],
'no-async-promise-executor': 'error',
'no-await-in-loop': 'warn',
'no-bitwise': 'error',
'no-caller': 'error',
'no-compare-neg-zero': 'error',
'no-cond-assign': 'error',
'no-console': 'off',
'no-constant-condition': 'error',
'no-control-regex': 'warn',
'no-debugger': 'error',
'no-delete-var': 'error',
'no-dupe-args': 'error',
'no-dupe-else-if': 'error',
'no-dupe-keys': 'error',
'no-duplicate-case': 'error',
'no-empty': [
'error',
{
allowEmptyCatch: true
}
],
'no-empty-character-class': 'error',
'no-eval': 'error',
'no-ex-assign': 'error',
'no-extra-boolean-cast': 'error',
'no-fallthrough': 'off',
'no-func-assign': 'error',
'no-import-assign': 'error',
'no-inner-declarations': 'error',
'no-invalid-regexp': 'error',
'no-irregular-whitespace': 'error',
'no-label-var': 'error',
'no-loss-of-precision': 'error',
'no-misleading-character-class': 'error',
'no-new-wrappers': 'error',
'no-obj-calls': 'error',
'no-promise-executor-return': 'error',
'no-prototype-builtins': 'error',
'no-regex-spaces': 'error',
'no-setter-return': 'error',
'no-shadow': [
'error',
{
builtinGlobals: false,
hoist: 'all'
}
],
'no-shadow-restricted-names': 'error',
'no-sparse-arrays': 'error',
'no-template-curly-in-string': 'warn',
'no-throw-literal': 'error',
'no-undef': 'error',
'no-undef-init': 'error',
'no-underscore-dangle': 'off',
'no-unexpected-multiline': 'error',
'no-unreachable': 'warn',
'no-unreachable-loop': 'warn',
'no-unsafe-finally': 'error',
'no-unsafe-negation': 'error',
'no-unsafe-optional-chaining': 'error',
'no-unused-expressions': [
'error',
{
allowTernary: true
}
],
'no-unused-labels': 'error',
'no-unused-vars': 'off',
'no-var': 'error',
'object-shorthand': [
'warn',
'methods'
],
'one-var': [
'error',
'never'
],
'prefer-arrow-callback': 'warn',
'prefer-const': 'error',
'prefer-rest-params': 'warn',
'radix': 'error',
'require-atomic-updates': 'warn',
'use-isnan': 'error',
'valid-typeof': 'warn'
}
}
]

10
index.ts Normal file
View File

@ -0,0 +1,10 @@
import s from "./Schema"
s.string()
/*
buffer
date
file
record
*/

76
items/array.ts Normal file
View File

@ -0,0 +1,76 @@
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
}
/**
* transform the array so it only contains one of each elements
*/
@parceable()
public unique(): this {
this.postProcess.push((input) => input.filter((it, idx) => input.indexOf(it) === idx))
return this
}
public parse(input: unknown, options?: { fast?: boolean }): ValidationResult<Array<SchemaInfer<Type>>> {
// check errors from itself
let { 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<Type>>
}
const clone: Array<SchemaInfer<Type>> = []
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 {
clone.push(res.object)
}
}
if (errs.length > 0) {
return {
valid: false,
object: clone as any,
errors: errs
}
}
return {
valid: true,
object: clone as any
}
}
public override isOfType(input: unknown): input is Array<SchemaInfer<Type>> {
return Array.isArray(input)
}
}

30
items/boolean.ts Normal file
View File

@ -0,0 +1,30 @@
import { parceable } from "../Schema"
import SchemaItem from "../SchemaItem"
export default class SchemaBoolean extends SchemaItem<boolean> {
/**
* @param [trueValue='true'] the truhty value (default to `'true'`)
* @param [falseValue='false'] the falthy value (default to `'false'`)
*/
@parceable()
public parseString(trueValue = 'true', falseValue = 'false'): this {
this.addPreProcess((it) => {
if (typeof it !== 'string') {
return it
}
if (it === trueValue) {
return true
}
if (it === falseValue) {
return false
}
return it
})
return this
}
public override isOfType(input: unknown): input is boolean {
return typeof input === 'boolean'
}
}

31
items/enum.ts Normal file
View File

@ -0,0 +1,31 @@
import SchemaItem from '../SchemaItem'
export type EnumLike = {
[k: string]: string | number
[n: number]: string
}
export default class SchemaEnum<E extends EnumLike> extends SchemaItem<E[keyof E]> {
private type: 'string' | 'number' = 'string'
public constructor(private templateEnum: E) {
super()
// get the type of Enum used (Number enums have reverse maping while string enums don't)
const firstValue = Object.values(templateEnum)[0]
if (typeof firstValue === 'number' || typeof templateEnum[firstValue] === 'number') {
this.type = 'number'
}
// test above === numebr
this.validations.push({
fn: (input) => Object.values(this.templateEnum).includes(input),
message: `Input is not part of ${templateEnum.constructor.name}`
})
}
public override isOfType(input: unknown): input is E[keyof E] {
return typeof input === this.type
}
}

13
items/literal.ts Normal file
View File

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

29
items/nullable.ts Normal file
View File

@ -0,0 +1,29 @@
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 unwrap() {
return this.child
}
public parse(input: unknown, options?: { fast?: boolean }): ValidationResult<SchemaInfer<Type>> {
if (this.isNull(input)) {
return {
valid: true,
object: undefined
}
}
return this.child.parse(input)
}
public override isOfType(input: unknown): input is SchemaInfer<Type> | undefined {
return Array.isArray(input)
}
private isNull(value: unknown): value is undefined | null {
return typeof value === 'undefined' || value === null
}
}

95
items/number.ts Normal file
View File

@ -0,0 +1,95 @@
import { parceable } from '../Schema'
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)
* @param message the message sent if not valid
*/
@parceable()
public lte(value: number, message?: string): this {
this.addValidation({
fn(input) {
return input <= value
},
error: message
})
return this
}
/**
* validate that the number is more or equal than {@link value}
* @param value the minimum value (inclusive)
* @param message the message sent if not valid
*/
@parceable()
public gte(value: number, message?: string): this {
this.addValidation({
fn(input) {
return input >= value
},
error: message
})
return this
}
/**
* validate that the number is less than {@link value}
* @param value the maxumum value (exclusive)
* @param message the message sent if not valid
*/
@parceable()
public lt(value: number, message?: string): this {
this.addValidation({
fn(input) {
return input < value
},
error: message
})
return this
}
/**
* validate that the number is more than {@link value}
* @param value the minimum value (exclusive)
* @param message the message sent if not valid
*/
@parceable()
public gt(value: number, message?: string): this {
this.addValidation({
fn(input) {
return input > value
},
error: message
})
return this
}
/**
* Try to parse strings before validating
*/
@parceable()
public parseString(): this {
this.addPreProcess((input) =>
typeof input === 'string' ? Number.parseFloat(input) : input
)
return this
}
public override isOfType(input: unknown): input is number {
return typeof input === 'number' && !Number.isNaN(input)
}
}

59
items/object.ts Normal file
View File

@ -0,0 +1,59 @@
import { isObject, objectLoop, objectClone } from '@dzeio/object-util'
import SchemaItem from "../SchemaItem"
import { SchemaInfer, ValidationResult } from "../types"
type ModelInfer<M extends Record<string, SchemaItem<any>>> = {
[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'
public constructor(public readonly model: T) {
super(arguments)
}
public override parse(input: unknown, options?: { fast?: boolean }): ValidationResult<ModelInfer<T>> {
// check errors from itself
let { valid, object, errors = [] } = super.parse(objectClone(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<ModelInfer<T>>
}
// loop through the childs
objectLoop(this.model, (childSchema, key) => {
const childValue = object![key]
// parse the child
const child = childSchema.parse(childValue)
// add errors
if ((child.errors?.length ?? 0) > 0) {
errors.push(...child.errors!)
}
// @ts-expect-error while it's a generic we know by proof above that it's valid !
object![key] = child.object
// skip rest of items if current one is invalid
return child.valid || !options?.fast
})
// answer !
return {
valid: errors.length === 0,
errors: errors,
object: object
} as ValidationResult<ModelInfer<T>>
}
public override isOfType(input: unknown): input is ModelInfer<T> {
return isObject(input)
}
}

96
items/string.ts Normal file
View File

@ -0,0 +1,96 @@
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
* @param value the minimum length of the text
* @param message the message to display on an error
*/
@parceable()
public min(value: number, message?: string): this {
this.addValidation({
fn(input) {
return input.length >= value
},
error: message
})
return this
}
/**
* transform the final text to the defined casing
* @param casing the final casing
*/
@parceable()
public toCasing(casing: 'lower' | 'upper'): this {
const fn: keyof string = casing === 'lower' ? 'toLowerCase' : 'toUpperCase'
this.addPostProcess((it) => it[fn]())
return this
}
/**
* force the input text to be a maximum of `value` size
* @param value the maximum length of the text
* @param message the message to display on an error
*/
@parceable()
public max(value: number, message?: string): this {
this.addValidation({
fn(input) {
return input.length <= value
},
error: message
})
return this
}
/**
* the value must not be empty (`''`)
* @param message
* @returns
*/
@parceable()
public notEmpty(message?: string): this {
this.addValidation({
fn(input) {
return input !== ''
},
error: message
})
return this
}
/**
* force the input text to respect a Regexp
* @param regex the regex to validate against
* @param message the message to display on an error
*/
@parceable()
public regex(regex: RegExp, message?: string): this {
this.addValidation({
fn(input) {
return regex.test(input)
},
error: message
})
return this
}
public override isOfType(input: unknown): input is string {
return typeof input === 'string'
}
}

49
items/union.ts Normal file
View File

@ -0,0 +1,49 @@
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>> {
private schemas: T
public constructor(...schemas: T) {
super()
this.schemas = schemas
}
public parse(input: unknown, options?: { fast?: boolean }): ValidationResult<SchemaInfer<T[number]>> {
// check errors from itself
let { 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<T[number]>>
}
// loop through the childs
for (const schema of this.schemas) {
const validation = schema.parse(input, options)
if (validation.valid) {
return validation
} else {
errors.push(...validation.errors)
}
}
// answer !
return {
valid: false,
errors: errors,
object: object
} as ValidationResult<SchemaInfer<T[number]>>
}
public override isOfType(input: unknown): input is ItemType<T> {
return this.schemas.some((it) => it.isOfType(input))
}
}

13
package.json Normal file
View File

@ -0,0 +1,13 @@
{
"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"
}
}

183
tests/Schema.test.ts Normal file
View File

@ -0,0 +1,183 @@
import { expect, test } from 'bun:test'
import s from '../Schema'
test('number enum', () => {
enum Test {
A,
B
}
// test by enum call
expect(s.enum(Test).parse(Test.A).valid).toBe(true)
// test by value call
expect(s.enum(Test).parse(0).valid).toBe(true)
// test by key call
expect(s.enum(Test).parse('A').valid).toBe(false)
})
test('string enum', () => {
enum Test {
A = 'B',
B = 'A',
C = 'D'
}
// test by enumn call
expect(s.enum(Test).parse(Test.A).valid).toBe(true)
// test by raw call
expect(s.enum(Test).parse('A').valid).toBe(true)
// test by key call
expect(s.enum(Test).parse('C').valid).toBe(false)
})
test('literal', () => {
const schema = s.literal('a')
expect(schema.parse('a').valid).toBe(true)
expect(schema.parse('b').valid).toBe(false)
})
test('nullable', () => {
const schema = s.nullable(s.string())
expect(schema.parse('a').valid).toBe(true)
expect(schema.parse(undefined).valid).toBe(true)
})
test('number', () => {
const schema = s.number()
expect(schema.parse(1).valid).toBe(true)
expect(schema.parse('1').valid).toBe(false)
let c = s.number().gt(2)
expect(c.parse(3).valid).toBe(true)
expect(c.parse(2).valid).toBe(false)
c = s.number().lt(2)
expect(c.parse(1).valid).toBe(true)
expect(c.parse(2).valid).toBe(false)
c = s.number().max(2)
expect(c.parse(1).valid).toBe(true)
expect(c.parse(2).valid).toBe(true)
expect(c.parse(3).valid).toBe(false)
c = s.number().min(2)
expect(c.parse(1).valid).toBe(false)
expect(c.parse(2).valid).toBe(true)
expect(c.parse(3).valid).toBe(true)
c = s.number().parseString()
expect(c.parse(1).valid).toBe(true)
expect(c.parse('2').valid).toBe(true)
expect(c.parse(true).valid).toBe(false)
})
test('object', () => {
const schema = s.object({
a: s.string(),
b: s.number()
})
expect(schema.parse({ a: 'a', b: 1 }).valid).toBe(true)
expect(schema.parse({ a: 'a', b: '1' }).valid).toBe(false)
})
test('string', () => {
const schema = s.string()
expect(schema.parse('1').valid).toBe(true)
expect(schema.parse(1).valid).toBe(false)
// min length
let c = s.string()
.minLength(8)
expect(c.parse('12345678').valid).toBe(true)
expect(c.parse('1234567').valid).toBe(false)
// max length
c = s.string()
.maxLength(8)
expect(c.parse('12345678').valid).toBe(true)
expect(c.parse('123456789').valid).toBe(false)
// to lower case
c = s.string()
.toCasing('lower')
expect(c.parse('HELLO WORLD').valid).toBe(true)
expect(c.parse('HELLO WORLD').object).toBe('hello world')
// to upper case
c = s.string()
.toCasing('upper')
expect(c.parse('hello world').valid).toBe(true)
expect(c.parse('hello world').object).toBe('HELLO WORLD')
// to not empty
c = s.string()
.notEmpty()
expect(c.parse('hello world').valid).toBe(true)
expect(c.parse('').valid).toBe(false)
// regex validation
c = s.string()
.regex(/llo/g)
expect(c.parse('hello world').valid).toBe(true)
expect(c.parse('hell world').valid).toBe(false)
})
test('union', () => {
const schema = s.union(s.string(), s.number())
expect(schema.parse('1').valid).toBe(true)
expect(schema.parse(1).valid).toBe(true)
expect(schema.parse(true).valid).toBe(false)
})
test('union advanced', () => {
const schema = s.union(s.object({
type: s.literal('pokemon' as const),
value: s.string()
}), s.object({
type: s.literal('pouet' as const),
value: s.number()
}))
expect(schema.parse({
type: 'pokemon',
value: 'a'
}).valid).toBe(true)
expect(schema.parse({
type: 'pouet',
value: 'a'
}).valid).toBe(false)
expect(schema.parse({
type: 'pouet',
value: 123
}).valid).toBe(true)
})
test('array', () => {
const schema = s.array(s.string())
expect(schema.parse(['1']).valid).toBe(true)
expect(schema.parse([]).valid).toBe(true)
expect(schema.parse([1]).valid).toBe(false)
expect(schema.parse(['1', 1]).valid).toBe(false)
const a = s.array(s.string())
.unique()
expect(a.parse(['1', '2', '1']).valid).toBe(true)
expect(a.parse(['1', '2', '1']).object).toEqual(['1', '2'])
})
test('boolean', () => {
const schema = s.boolean()
expect(schema.parse(true).valid).toBe(true)
expect(schema.parse(false).valid).toBe(true)
const p = s.boolean().parseString('oui', 'non')
expect(p.parse('oui').valid).toBe(true)
expect(p.parse('pokemon').valid).toBe(false)
expect(p.parse(1).valid).toBe(false)
expect(p.parse('oui').object).toBe(true)
expect(p.parse('non').valid).toBe(true)
expect(p.parse('non').object).toBe(false)
expect(schema.parse('true').valid).toBe(false)
expect(schema.parse('false').valid).toBe(false)
})

10
tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"exclude": [
"cypress"
],
"compilerOptions": {
"baseUrl": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

29
types.d.ts vendored Normal file
View File

@ -0,0 +1,29 @@
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
}