diff --git a/index.d.ts b/index.d.ts index dd43bd1..c8bc7c7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,3 +1,4 @@ +import type { EnumType } from "typescript"; export type ValidationRuleName = | "any" @@ -23,7 +24,7 @@ export type ValidationRuleName = | "tuple" | "url" | "uuid" - | string; + | (string & {}); /** * Validation schema definition for "any" built-in validator @@ -41,47 +42,47 @@ export interface RuleAny extends RuleCustom { * @see https://github.com/icebob/fastest-validator#array */ export interface RuleArray extends RuleCustom { - /** - * Name of built-in validator - */ - type: "array"; - /** - * If true, the validator accepts an empty array []. - * @default true - */ - empty?: boolean; - /** - * Minimum count of elements - */ - min?: number; - /** - * Maximum count of elements - */ - max?: number; - /** - * Fixed count of elements - */ - length?: number; - /** - * The array must contain this element too - */ - contains?: T | T[]; + /** + * Name of built-in validator + */ + type: "array"; + /** + * If true, the validator accepts an empty array []. + * @default true + */ + empty?: boolean; + /** + * Minimum count of elements + */ + min?: number; + /** + * Maximum count of elements + */ + max?: number; + /** + * Fixed count of elements + */ + length?: number; + /** + * The array must contain this element too + */ + contains?: T | T[]; /** * The array must be unique (array of objects is always unique). */ unique?: boolean; - /** - * Every element must be an element of the enum array - */ - enum?: T[]; - /** - * Validation rules that should be applied to each element of array - */ - items?: ValidationRule; + /** + * Every element must be an element of the enum array + */ + enum?: T[]; + /** + * Validation rules that should be applied to each element of array + */ + items?: ValidationRule; /** * Wrap value into array if different type provided */ - convert?: boolean + convert?: boolean; } /** @@ -523,7 +524,7 @@ export interface RuleTuple extends RuleCustom { /** * If true, the validator accepts an empty array []. */ - empty?: boolean + empty?: boolean; /** * Validation rules that should be applied to the corresponding element of array */ @@ -904,13 +905,15 @@ export interface ValidationSchemaMetaKeys { /** * Definition for validation schema based on validation rules */ -export type ValidationSchema = ValidationSchemaMetaKeys & { +export type ValidationSchema = ValidationSchemaMetaKeys & { /** - * List of validation rules for each defined field + * List of validation rules for each defined field. + * Note that `boolean` is only acceptable for ValidationSchemaMetaKeys. + * However, omitting it here would cause TypeScript errors. + * @see https://www.typescriptlang.org/docs/handbook/2/objects.html#index-signatures */ - [key in keyof T]: ValidationRule | undefined | any; -} - + [key: string]: ValidationRule | boolean | undefined; +}; /** * Structure with description of validation error message @@ -961,7 +964,7 @@ export interface ValidatorConstructorOptions { /** * Immediately halt after the first error */ - haltOnFirstError?: boolean + haltOnFirstError?: boolean; /** * Default settings for rules @@ -1005,7 +1008,7 @@ export interface Context { customs: { [ruleName: string]: { schema: RuleCustom; messages: MessagesType }; }; - meta?: object; + meta?: Record; data: DATA; } @@ -1048,17 +1051,19 @@ export interface CheckFunctionOptions { meta?: object | null; } -export interface SyncCheckFunction { - (value: any, opts?: CheckFunctionOptions): true | ValidationError[] - async: false +export interface SyncCheckFunction { + (value: T, opts?: CheckFunctionOptions): true | ValidationError[]; + async: false; } -export interface AsyncCheckFunction { - (value: any, opts?: CheckFunctionOptions): Promise - async: true +export interface AsyncCheckFunction { + (value: T, opts?: CheckFunctionOptions): Promise; + async: true; } -export default class Validator { +export default class Validator< + VCO extends ValidatorConstructorOptions = ValidatorConstructorOptions +> { /** * List of possible error messages */ @@ -1078,7 +1083,7 @@ export default class Validator { * Constructor of validation class * @param {ValidatorConstructorOptions} opts List of possible validator constructor options */ - constructor(opts?: ValidatorConstructorOptions); + constructor(opts?: VCO); /** * Register a custom validation rule in validation object @@ -1132,9 +1137,15 @@ export default class Validator { * @param {ValidationSchema | ValidationSchema[]} schema Validation schema definition that should be used for validation * @return {(value: any) => (true | ValidationError[])} function that can be used next for validation of current schema */ - compile( - schema: ValidationSchema | ValidationSchema[] - ): SyncCheckFunction | AsyncCheckFunction; + compile< + S extends ValidationRule | ValidationSchema | ValidationSchema[], + CompiledType = + | TypeFromAnySchema + // We don't do type inference for `considerNullAsAValue`, to keep it simple. + | (VCO extends { considerNullAsAValue: true } ? any : never) + >( + schema: S + ): SyncCheckFunction | AsyncCheckFunction; /** * Native validation method to validate obj @@ -1142,9 +1153,9 @@ export default class Validator { * @param {ValidationSchema} schema Validation schema definition that should be used for validation * @return {{true} | ValidationError[]} */ - validate( - value: any, - schema: ValidationSchema + validate( + value: TypeFromValidationSchema, + schema: VS ): true | ValidationError[] | Promise; /** @@ -1153,7 +1164,12 @@ export default class Validator { * @return {ValidationRule} */ getRuleFromSchema( - name: ValidationRuleName | ValidationRuleName[] | ValidationSchema | ValidationSchema[] | { [key: string]: unknown } + name: + | ValidationRuleName + | ValidationRuleName[] + | ValidationSchema + | ValidationSchema[] + | { [key: string]: unknown } ): { messages: MessagesType; schema: ValidationSchema; @@ -1171,5 +1187,151 @@ export default class Validator { */ normalize( value: ValidationSchema | string | any - ): ValidationRule | ValidationSchema + ): ValidationRule | ValidationSchema; } + +/* + * + * INFERENCE TYPES + * + */ + +type TypeFromAnySchema< + Schema extends + | ValidationRuleName + | ValidationRuleObject + | ValidationRuleObject[] + | ValidationSchema + | ValidationSchema[] +> = Schema extends ValidationRule + ? TypeFromValidationRule + : // Basic ValidationSchema? + Schema extends ValidationSchema + ? TypeFromValidationSchema + : // ValidationSchema array? We take the union of each entry. + Schema extends ValidationSchema[] + ? { [K in number]: TypeFromValidationSchema }[number] + : never; + +/** + * Infers the type of a ValidationSchema. + * E.g. + * ```ts + * { param1: {type: "string"}, + * param2: {type: "number"} } + * ``` + * returns type `{ param1: string, param2: number}` + */ +type TypeFromValidationSchema = Optionalize<{ + [Param in Exclude< + keyof Schema, + keyof ValidationSchemaMetaKeys + >]: TypeFromValidationRule>; +}>; + +type TypeFromValidationRule = + VR extends ValidationRuleObject + ? TypeFromValidationRuleObject + : // VR is a string that is present in type name->type map? + VR extends keyof BasicValidatorTypeMap + ? BasicValidatorTypeMap[VR] + : // Array of ValidationRuleObjects. + VR extends ValidationRule[] + ? { [K in number]: TypeFromValidationRule }[number] + : // None of the above... + any; + +/** + * Infers type from fastest-validator schema property definition. + * + * E.g. + * - `{ type: "number", default: 2}` returns type `number | undefined`. + * - `{ type: "array", items: "string"}` returns type `string[]` + */ +type TypeFromValidationRuleObject = + // Base type inferred from the `type` property + | TypeFromValidationRuleInner + + // Include the type of `default` if it exists + | (VR extends { default: infer D } ? (D & {}) | undefined | null : never) + + // Allow `undefined` and `null` if `optional` is true. + | (VR extends { optional: true } ? undefined | null : never) + + // Include `null` if `optional` is true + | (VR extends { nullable: true } ? null : never); + +/** + * Infers the type from a fastest-validator string type, e.g. + * the string `"number"` returns type `number`, `"boolean"` returns `boolean`. + * + * Supports complex types `"array"`, `"object"` or `"multi"` too. In that case, + * provide the correct `"items"`, `"params"`, or `"rules"` schema. + * `"tuple"` or types like email are not supported. + */ +type TypeFromValidationRuleInner = + VR["type"] extends keyof BasicValidatorTypeMap + ? BasicValidatorTypeMap[VR["type"]] + : // Type array? + VR["type"] extends "array" + ? Array> + : // Type multi (union type)? + VR extends "multi" + ? MultiType + : // Type object? + VR extends "object" + ? TypeFromValidationSchema + : // TODO: Tuples + // None of the above... + any; + +/** Fastest-validator types with primitive mapping. */ +type BasicValidatorTypeMap = { + any: any; + boolean: boolean; + class: any; + currency: string; + custom: any; + date: string; + email: string; + enum: EnumType; + equal: any; + forbidden: any; + function: Function; + luhn: string; + mac: string; + number: number; + objectID: any; + record: object; + string: string; + tuple: any[]; + url: string; + uuid: string; +}; + +/** + * Infers schema definitions from an array of schema properties ("multitype") into one type. + * **Attention**: Using multi with more than one rule of type object fails. + */ +type MultiType< + ParameterSchemas extends (ValidationRuleObject | ValidationRuleName)[] = [] +> = { + [Index in keyof ParameterSchemas]: TypeFromValidationRule< + ParameterSchemas[Index] + >; +}[number]; + +/** + * A helper type that takes an object and makes properties optional + * if their type includes `undefined`. + * + * For example, for `{ a: string | undefined, b: string }`, it returns + * `{ a?: string | undefined, b: string }`. + */ +type Optionalize = { + // Pick optional properties and make them optional + [K in keyof T as undefined extends T[K] ? K : never]?: T[K]; +} & { + // Pick required properties + [K in keyof T as undefined extends T[K] ? never : K]: T[K]; +}; diff --git a/test/typescript/integration.spec.ts b/test/typescript/integration.spec.ts index e4a1cdb..f70f274 100644 --- a/test/typescript/integration.spec.ts +++ b/test/typescript/integration.spec.ts @@ -1,4 +1,4 @@ -import Validator from '../../'; +import Validator, { ValidationSchema, ValidationRule } from '../../'; describe('TypeScript Definitions', () => { describe('Test flat schema', () => { @@ -713,10 +713,10 @@ describe('TypeScript Definitions', () => { type: 'array', items: 'number', }, - ]; + ] satisfies ValidationRule; let check = v.compile(schema); - + it('should give true if first array is given', () => { let obj = ['hello', 'there', 'this', 'is', 'a', 'test']; @@ -736,6 +736,7 @@ describe('TypeScript Definitions', () => { it('should give error if the array is broken', () => { let obj = ['hello', 3]; + // @ts-expect-error let res = check(obj); expect(res).toBeInstanceOf(Array); @@ -749,6 +750,7 @@ describe('TypeScript Definitions', () => { it('should give error if the array is broken', () => { let obj = [true, false]; + // @ts-expect-error let res = check(obj); expect(res).toBeInstanceOf(Array); @@ -769,7 +771,7 @@ describe('TypeScript Definitions', () => { it('should compile and validate', () => { const schema = { valid: { type: 'object' }, - }; + } satisfies ValidationSchema; const check = v.compile(schema); expect(check).toBeInstanceOf(Function); @@ -785,7 +787,7 @@ describe('TypeScript Definitions', () => { it('should compile and validate', () => { const schema = { valid: { type: 'array' }, - }; + } satisfies ValidationSchema; const check = v.compile(schema); expect(check).toBeInstanceOf(Function); @@ -927,7 +929,8 @@ describe('TypeScript Definitions', () => { let schema = { name: 'string', $$strict: true, - }; + } satisfies ValidationSchema; + let check = v.compile(schema); @@ -962,10 +965,10 @@ describe('TypeScript Definitions', () => { }, }, $$strict: true, - }; + } satisfies ValidationSchema; let check = v.compile(schema); - + it('should give error if the object contains additional properties on the root-level', () => { let obj = { name: 'test', @@ -999,7 +1002,7 @@ describe('TypeScript Definitions', () => { street: 'string', }, }, - }; + } satisfies ValidationSchema; let check = v.compile(schema); @@ -1031,7 +1034,7 @@ describe('TypeScript Definitions', () => { status: { type: 'boolean', default: true }, tuple: { type: 'tuple', items: [{ type: 'number', default: 666 }, { type: 'string', default: 'lucifer' }] }, array: { type: 'array', items: { type: 'string', default: 'bar' } }, - }; + } satisfies ValidationSchema; let check = v.compile(schema); it('should fill not defined properties', () => { @@ -1071,7 +1074,7 @@ describe('TypeScript Definitions', () => { { type: "number", optional: true }, ], }, - }; + } satisfies ValidationSchema; const check = v.compile(schema); expect(check({})).toBe(true); @@ -1083,7 +1086,7 @@ describe('TypeScript Definitions', () => { }); it("should not throw error if value is null", () => { - const schema = { foo: { type: "number", optional: true } }; + const schema = { foo: { type: "number", optional: true } } satisfies ValidationSchema; const check = v.compile(schema); const o = { foo: null, array: [null], tuple: [null] }; @@ -1092,7 +1095,7 @@ describe('TypeScript Definitions', () => { }); it("should not throw error if value exist", () => { - const schema = { foo: { type: "number", optional: true } }; + const schema = { foo: { type: "number", optional: true } } satisfies ValidationSchema; const check = v.compile(schema); expect(check({ foo: 2 })).toBe(true); @@ -1109,8 +1112,7 @@ describe('TypeScript Definitions', () => { { type: "number", optional: true, default: 666 }, ], }, - - }; + } satisfies ValidationSchema; const check = v.compile(schema); const o1 = { foo: 2, array: [], tuple: [6] }; @@ -1132,15 +1134,17 @@ describe('TypeScript Definitions', () => { const v = new Validator(); it("should throw error if value is undefined", () => { - const schema = { foo: { type: "number", nullable: true } }; + const schema = { foo: { type: "number", nullable: true } } satisfies ValidationSchema; const check = v.compile(schema); + // @ts-expect-error expect(check(check)).toBeInstanceOf(Array); + // @ts-expect-error expect(check({ foo: undefined })).toBeInstanceOf(Array); }); it("should not throw error if value is null", () => { - const schema = { foo: { type: "number", nullable: true } }; + const schema = { foo: { type: "number", nullable: true } } satisfies ValidationSchema; const check = v.compile(schema); const o = { foo: null }; @@ -1149,13 +1153,13 @@ describe('TypeScript Definitions', () => { }); it("should not throw error if value exist", () => { - const schema = { foo: { type: "number", nullable: true } }; + const schema = { foo: { type: "number", nullable: true } } satisfies ValidationSchema; const check = v.compile(schema); expect(check({ foo: 2 })).toBe(true); }); it("should set default value if there is a default", () => { - const schema = { foo: { type: "number", nullable: true, default: 5 } }; + const schema = { foo: { type: "number", nullable: true, default: 5 } } satisfies ValidationSchema; const check = v.compile(schema); const o1 = { foo: 2 }; @@ -1168,7 +1172,7 @@ describe('TypeScript Definitions', () => { }); it("should not set default value if current value is null", () => { - const schema = { foo: { type: "number", nullable: true, default: 5 } }; + const schema = { foo: { type: "number", nullable: true, default: 5 } } satisfies ValidationSchema; const check = v.compile(schema); const o = { foo: null }; @@ -1177,7 +1181,7 @@ describe('TypeScript Definitions', () => { }); it("should work with optional", () => { - const schema = { foo: { type: "number", nullable: true, optional: true } }; + const schema = { foo: { type: "number", nullable: true, optional: true } } satisfies ValidationSchema; const check = v.compile(schema); expect(check({ foo: 3 })).toBe(true); @@ -1186,7 +1190,7 @@ describe('TypeScript Definitions', () => { }); it("should work with optional and default", () => { - const schema = { foo: { type: "number", nullable: true, optional: true, default: 5 } }; + const schema = { foo: { type: "number", nullable: true, optional: true, default: 5 } } satisfies ValidationSchema; const check = v.compile(schema); expect(check({ foo: 3 })).toBe(true); @@ -1201,7 +1205,7 @@ describe('TypeScript Definitions', () => { }); it("should accept null value when optional", () => { - const schema = { foo: { type: "number", nullable: false, optional: true } }; + const schema = { foo: { type: "number", nullable: false, optional: true } } satisfies ValidationSchema; const check = v.compile(schema); expect(check({ foo: 3 })).toBe(true); @@ -1211,22 +1215,27 @@ describe('TypeScript Definitions', () => { }); it("should accept null as value when required", () => { - const schema = {foo: {type: "number", nullable: true, optional: false}}; + const schema = {foo: {type: "number", nullable: true, optional: false}} satisfies ValidationSchema; const check = v.compile(schema); expect(check({ foo: 3 })).toBe(true); + // @ts-expect-error expect(check({ foo: undefined })).toEqual([{"actual": undefined, "field": "foo", "message": "The 'foo' field is required.", "type": "required"}]); + // @ts-expect-error expect(check({})).toEqual([{"actual": undefined, "field": "foo", "message": "The 'foo' field is required.", "type": "required"}]); expect(check({ foo: null })).toBe(true); }); it("should not accept null as value when required and not explicitly not nullable", () => { - const schema = {foo: {type: "number", optional: false}}; + const schema = {foo: {type: "number", optional: false}} satisfies ValidationSchema; const check = v.compile(schema); expect(check({ foo: 3 })).toBe(true); + // @ts-expect-error expect(check({ foo: undefined })).toEqual([{"actual": undefined, "field": "foo", "message": "The 'foo' field is required.", "type": "required"}]); + // @ts-expect-error expect(check({})).toEqual([{"actual": undefined, "field": "foo", "message": "The 'foo' field is required.", "type": "required"}]); + // @ts-expect-error expect(check({ foo: null })).toEqual([{"actual": null, "field": "foo", "message": "The 'foo' field is required.", "type": "required"}]); }); }); @@ -1235,15 +1244,16 @@ describe('TypeScript Definitions', () => { const v = new Validator({considerNullAsAValue: true}); it("should throw error if value is undefined", () => { - const schema = { foo: { type: "number" } }; + const schema = { foo: { type: "number" } } satisfies ValidationSchema; const check = v.compile(schema); expect(check(check)).toBeInstanceOf(Array); expect(check({ foo: undefined })).toBeInstanceOf(Array); }); + type T = {a: boolean} extends { a: true } ? true : false; it("should not throw error if value is null", () => { - const schema = { foo: { type: "number" } }; + const schema = { foo: { type: "number" } } satisfies ValidationSchema; const check = v.compile(schema); const o = { foo: null }; @@ -1252,13 +1262,13 @@ describe('TypeScript Definitions', () => { }); it("should not throw error if value exist", () => { - const schema = { foo: { type: "number" } }; + const schema = { foo: { type: "number" } } satisfies ValidationSchema; const check = v.compile(schema); expect(check({ foo: 2 })).toBe(true); }); it("should set default value if there is a default", () => { - const schema = { foo: { type: "number", default: 5 } }; + const schema = { foo: { type: "number", default: 5 } } satisfies ValidationSchema; const check = v.compile(schema); const o1 = { foo: 2 }; @@ -1271,7 +1281,7 @@ describe('TypeScript Definitions', () => { }); it("should not set default value if current value is null", () => { - const schema = { foo: { type: "number", default: 5 } }; + const schema = { foo: { type: "number", default: 5 } } satisfies ValidationSchema; const check = v.compile(schema); const o = { foo: null }; @@ -1280,7 +1290,7 @@ describe('TypeScript Definitions', () => { }); it("should set default value if current value is null but can't be", () => { - const schema = { foo: { type: "number", default: 5, nullable: false } }; + const schema = { foo: { type: "number", default: 5, nullable: false } } satisfies ValidationSchema; const check = v.compile(schema); const o = { foo: null }; @@ -1289,7 +1299,7 @@ describe('TypeScript Definitions', () => { }); it("should set default value if current value is null but optional", () => { - const schema = { foo: { type: "number", default: 5, nullable: false, optional: true } }; + const schema = { foo: { type: "number", default: 5, nullable: false, optional: true } } satisfies ValidationSchema; const check = v.compile(schema); const o = { foo: null }; @@ -1298,7 +1308,7 @@ describe('TypeScript Definitions', () => { }); it("should work with optional", () => { - const schema = { foo: { type: "number", optional: true } }; + const schema = { foo: { type: "number", optional: true } } satisfies ValidationSchema; const check = v.compile(schema); expect(check({ foo: 3 })).toBe(true); @@ -1307,7 +1317,7 @@ describe('TypeScript Definitions', () => { }); it("should work with optional and default", () => { - const schema = { foo: { type: "number", optional: true, default: 5 } }; + const schema = { foo: { type: "number", optional: true, default: 5 } } satisfies ValidationSchema; const check = v.compile(schema); expect(check({ foo: 3 })).toBe(true); @@ -1322,7 +1332,7 @@ describe('TypeScript Definitions', () => { }); it("should not accept null value even if optional", () => { - const schema = { foo: { type: "number", nullable: false, optional: true } }; + const schema = { foo: { type: "number", nullable: false, optional: true } } satisfies ValidationSchema; const check = v.compile(schema); expect(check({ foo: 3 })).toBe(true); @@ -1332,7 +1342,7 @@ describe('TypeScript Definitions', () => { }); it("should not accept null as value", () => { - const schema = {foo: {type: "number", nullable: false}}; + const schema = {foo: {type: "number", nullable: false}} satisfies ValidationSchema; const check = v.compile(schema); expect(check({ foo: 3 })).toBe(true); @@ -1380,7 +1390,7 @@ describe("Test async mode", () => { name: { type: "string", custom: custom1 }, username: { type: "custom", custom: custom2 }, age: { type: "even" } - }; + } satisfies ValidationSchema; const check = v.compile(schema); it("should be async", () => { @@ -1432,10 +1442,10 @@ describe("Test context meta", () => { name: { type: "string", custom: (value, errors, schema, name, parent, context) => { expect(context.meta).toEqual({ a: "from-meta" }); - return context.meta.a; + return context.meta?.a; } }, - }; + } satisfies ValidationSchema; const check = v.compile(schema); it("should call custom async validators", () => { diff --git a/test/typescript/tsconfig.json b/test/typescript/tsconfig.json index 49fcee1..6c4ad09 100644 --- a/test/typescript/tsconfig.json +++ b/test/typescript/tsconfig.json @@ -1,24 +1,22 @@ { - "compilerOptions": { - "target": "es6", - "lib": [ "es2015" ], - "sourceMap": false, - "module": "commonjs", - "moduleResolution": "node", - "isolatedModules": false, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "declaration": false, - "noImplicitAny": false, - "removeComments": true, - "noLib": false, + "compilerOptions": { + "target": "es6", + "lib": ["es2015"], + "sourceMap": false, + "module": "commonjs", + "moduleResolution": "node", + "isolatedModules": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declaration": false, + "noImplicitAny": false, + "removeComments": true, + "noLib": false, "strict": true, "strictFunctionTypes": false, - "preserveConstEnums": true, - "suppressImplicitAnyIndexErrors": true, + "preserveConstEnums": true, "esModuleInterop": true, - "baseUrl": ".", - "allowJs": true - }, - "include": ["test", "lib", "index.d.ts", "index.js"] + "baseUrl": ".", + "allowJs": true + } }