From 77898d5727e102a601804058bde620ceb08be557 Mon Sep 17 00:00:00 2001 From: Lchemist Date: Sat, 3 Apr 2021 20:47:43 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20Schema=20support=20fo?= =?UTF-8?q?r=20Union=20type=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.test.ts | 41 ++++++++++++++++-- src/index.ts | 107 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 118 insertions(+), 30 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 375ee51..ad8cdaf 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -35,6 +35,8 @@ const generatorFunction = function* () {} const generator = generatorFunction() const localMap = new Map() const symbol = Symbol('key') +const SchemaA = T.Schema({ a: T.Number, b: T.String }) +const SchemaB = T.Schema({ a: T.String }) const TG = { ...T, @@ -59,6 +61,8 @@ const TG = { 'Nullable(String)': T.Nullable(T.String), 'Nullable(Undefined)': T.Nullable(T.Undefined), 'Union(Number, String, Array())': T.Union(T.Number, T.String, T.Array()), + 'Union(Schema({ a: Number, b: String }), Schema({ a: String }))': T.Union(SchemaA, SchemaB), + 'Union(Schema({ a: Number, b: String }), { a: "true" })': T.Union(SchemaA, { a: 'true' }), 'NonNullable(Union(String, Null))': T.NonNullable(T.Union(T.String, T.Null)), } @@ -228,6 +232,35 @@ const tests: Tests = [ [0, -3.33, '', 'str', [], [1, 2, 3]], [false, null, undefined, {}], ], + [ + 'Union(Schema({ a: Number, b: String }), Schema({ a: String }))', + [{ a: 0, b: '' }, { a: '' }, { a: '', b: '' }], + [ + { a: 0 }, + { b: '' }, + { a: false }, + { a: false, b: '' }, + { a: 0, b: false }, + { a: 0, b: '', c: 3 }, + defaultBanned, + ], + ], + [ + 'Union(Schema({ a: Number, b: String }), { a: "true" })', + [{ a: 0, b: '' }, { a: 'true' }, { a: 'true', b: '' }], + [ + { a: 1 }, + { a: '' }, + { a: true }, + { b: '' }, + { a: '', b: '' }, + { a: false }, + { a: false, b: '' }, + { a: 0, b: false }, + { a: 0, b: '', c: 3 }, + defaultBanned, + ], + ], ['NonNullable(Union(String, Null))', ['', 'a'], defaultBanned.slice(1)], ] @@ -392,11 +425,13 @@ describe('CustomNumberRange', () => { // ----------------------------------------------------------------------------- const Age = T.Number.config({ validate: v => typeof v === 'number' && v >= 0 }) -// Custom schema -const JobSchema = T.Schema({ +const definition = { title: T.String, salary: T.Nullable(T.Number), -}) +} + +// Custom schema +const JobSchema = T.Schema(definition) // Use closure to use type guard directly & prevent naming conflicts with JS built-in objects const PersonSchema = createSchema(({ Boolean, String, Number, Array, Optional, Union }) => ({ diff --git a/src/index.ts b/src/index.ts index 25cf85b..4100e3e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ export type PlainObject = Record export type TypeGuard = { // eslint-disable-next-line no-use-before-define - [DEF]: 'typeguard' | SchemaDefinition + [DEF]: D | 'typeguard' /** Returns a Boolean value that indicates whether the given value has correct type. */ validate: (value: unknown) => boolean /** @@ -32,7 +32,7 @@ export type TypeGuard = { * @experimental **/ transform: (value: unknown) => unknown - /** Configs type guard's validation function and/or transformation function. */ + /** Configs type guard's validator function and/or transformer function. */ config: (configs: Partial, 'validate' | 'transform'>>) => TypeGuard } @@ -62,6 +62,9 @@ export const isTypeGuard = (obj: unknown): obj is TypeGuard => export const isPlainObject = (obj: unknown): obj is PlainObject => toString.call(obj) === '[object Object]' +export const isSchemaTypeGuard = (obj: unknown): obj is TypeGuard> => + isTypeGuard(obj) && isPlainObject(obj[DEF]) + export const isProxy = (obj: unknown): boolean => { if (typeof obj !== 'object') return false if (typeof window !== 'undefined') { @@ -105,9 +108,13 @@ const validateValue = (value: unknown, definition: TypeDefinition): boolean => { return definition === value } -const validateObject = (obj: unknown, definition: SchemaDefinition, { partial = false } = {}) => { +const validateObject = ( + obj: PlainObject, + definition: SchemaDefinition, + { partial = false } = {} +) => { // Check type - if (typeof obj !== 'object' || obj === null) return false + if (!isPlainObject(obj)) return false // Check keys const objKeys = Reflect.ownKeys(obj) const defKeys = Reflect.ownKeys(definition) @@ -152,6 +159,53 @@ const constructor = ( } as TypeGuard) ) +const Union = ( + ...types: T +): TypeGuard> => { + /** Union schema definition by merging subtypes' schema definitions. */ + let unionDef: SchemaDefinition | undefined + for (const type of types) { + let schemaDef: SchemaDefinition = Object.create(null) + if (isTypeGuard(type)) { + const typeDef = type[DEF] + if (isPlainObject(typeDef)) { + if (!unionDef) unionDef = Object.create(null) + schemaDef = typeDef as SchemaDefinition + } + } else if (isPlainObject(type)) { + if (!unionDef) unionDef = Object.create(null) + schemaDef = type as SchemaDefinition + } + if (typeof unionDef === 'object') { + for (const key of Reflect.ownKeys(schemaDef)) { + unionDef[key as string] = + key in unionDef + ? Union(unionDef[key as string], schemaDef[key as string]) + : schemaDef[key as string] + } + } + } + + const unionSchema = constructor(obj => + typeof unionDef === 'object' ? validateObject(obj as PlainObject, unionDef) : false + ) + + return constructor( + value => { + if (!isPlainObject(value)) return types.some(type => validateValue(value, type)) + return [unionSchema, ...types].some(type => + isTypeGuard(type) ? type.validate(value) : validateValue(value, type) + ) + }, + value => { + const type = types.find(type => validateValue(value, type)) + if (isTypeGuard(type)) return type.transform(value) + return value + }, + unionDef ? { definition: unionDef } : undefined + ) +} + export const TypeGuards = { // // Primitive types @@ -161,7 +215,7 @@ export const TypeGuards = { * Type guard for string whose value is a boolean indicator. * Useful for handling query parameters, or handling data read from file. * - * @example "true" | "TURE" | "1" + * @example "true" | "TRUE" | "1" **/ StringBoolean: constructor<'true' | 'TRUE' | 'false' | 'FALSE' | '1' | '0'>( value => typeof value === 'string' && ['true', 'false', '1', '0'].includes(value.toLowerCase()), @@ -282,9 +336,6 @@ export const TypeGuards = { value => value instanceof BigUint64Array && value.BYTES_PER_ELEMENT === 8 ), Function: constructor(value => typeof value === 'function'), - // - // Schemas - // ------------------------------------------------------------------------------------------- Record: < K extends | Array @@ -319,10 +370,13 @@ export const TypeGuards = { } return true }), + // + // Schemas + // ------------------------------------------------------------------------------------------- /** @param definition An object that describes the definition of Schema. */ Schema: (definition: D): TypeGuard> => constructor( - obj => validateObject(obj, definition), + obj => validateObject(obj as PlainObject, definition), obj => transformObject(obj, definition), { definition } ), @@ -333,9 +387,9 @@ export const TypeGuards = { Partial: >>( schema: S ): TypeGuard>>> => { - const definition = isTypeGuard(schema) ? schema[DEF] : schema + const definition = isSchemaTypeGuard(schema) ? schema[DEF] : schema return constructor( - obj => validateObject(obj, definition, { partial: true }), + obj => validateObject(obj as PlainObject, definition, { partial: true }), obj => transformObject(obj, definition), { definition } ) @@ -347,16 +401,22 @@ export const TypeGuards = { Required: >>( schema: S ): TypeGuard>>> => { - const definition = isTypeGuard(schema) ? schema[DEF] : schema + const definition = isSchemaTypeGuard(schema) ? schema[DEF] : schema return constructor( - obj => validateObject(obj, definition), + obj => validateObject(obj as PlainObject, definition), obj => transformObject(obj, definition), { definition } ) }, /** - * Returns a Schema type guard whose properties are picked from the given schema. - * @param schema A schema type guard, or an object that describes the definition of Schema. + * Returns a Schema type guard whose properties are picked from the given Schema. + * @param schema A Schema type guard, or an object that describes the definition of Schema. + * @param keys Keys of the properties to be picked from the original Schema + * + * @note In TypeScript, static type `Pick` will only apply to the common properties + * of subtypes of the static type `Union`, see: https://github.com/microsoft/TypeScript/issues/28339. + * However, the `Pick` runtime type guard will be distributive over all of + * `Union` runtime type guard's sub-type-guards. Exp: Pick(Union(SchemaA, SchemaB), ['prop']) **/ Pick: < S extends SchemaDefinition | TypeGuard>, @@ -365,14 +425,14 @@ export const TypeGuards = { schema: S, keys: K[] ): TypeGuard>, typeof keys[number]>> => { - const definition = isTypeGuard(schema) ? schema[DEF] : schema + const definition = isSchemaTypeGuard(schema) ? schema[DEF] : schema const defKeys = Reflect.ownKeys(definition) const pickedDefinition = Object.create(definition) for (const k of defKeys as string[]) { if ((keys as string[]).includes(k)) pickedDefinition[k] = definition[k] } return constructor( - obj => validateObject(obj, pickedDefinition), + obj => validateObject(obj as PlainObject, pickedDefinition), obj => transformObject(obj, pickedDefinition), { definition: pickedDefinition } ) @@ -404,18 +464,11 @@ export const TypeGuards = { value => (isTypeGuard(type) ? type.transform(value) : value) ), /** - * Describes value that can be one of or all of the given types. + * Describes value that is one of or union of the given types. * @example Union(A, B, C) = A | B | C + * @note When calling `transform` API, only the first argument's transformer function will be used. **/ - Union: (...types: T): TypeGuard> => - constructor( - value => types.some(type => validateValue(value, type)), - value => { - const type = types.find(type => validateValue(value, type)) - if (isTypeGuard(type)) return type.transform(value) - return value - } - ), + Union, // // Experimental // -------------------------------------------------------------------------------------------