Skip to content

Commit

Permalink
feat: ✨ add Schema support for Union type guard
Browse files Browse the repository at this point in the history
  • Loading branch information
Lchemist committed Apr 4, 2021
1 parent bba2462 commit 77898d5
Show file tree
Hide file tree
Showing 2 changed files with 118 additions and 30 deletions.
41 changes: 38 additions & 3 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)),
}

Expand Down Expand Up @@ -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)],
]

Expand Down Expand Up @@ -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 }) => ({
Expand Down
107 changes: 80 additions & 27 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ export type PlainObject<T = unknown> = Record<string | number | symbol, T>

export type TypeGuard<D = unknown> = {
// 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
/**
* Transforms the given value into something else.
* @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<Pick<TypeGuard<D>, 'validate' | 'transform'>>) => TypeGuard<D>
}

Expand Down Expand Up @@ -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<Schema<SchemaDefinition>> =>
isTypeGuard(obj) && isPlainObject(obj[DEF])

export const isProxy = (obj: unknown): boolean => {
if (typeof obj !== 'object') return false
if (typeof window !== 'undefined') {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -152,6 +159,53 @@ const constructor = <D>(
} as TypeGuard<D>)
)

const Union = <T extends TypeDefinition[]>(
...types: T
): TypeGuard<TypeOf<typeof types[number]>> => {
/** 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
Expand All @@ -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()),
Expand Down Expand Up @@ -282,9 +336,6 @@ export const TypeGuards = {
value => value instanceof BigUint64Array && value.BYTES_PER_ELEMENT === 8
),
Function: constructor<Function>(value => typeof value === 'function'),
//
// Schemas
// -------------------------------------------------------------------------------------------
Record: <
K extends
| Array<string | number | symbol>
Expand Down Expand Up @@ -319,10 +370,13 @@ export const TypeGuards = {
}
return true
}),
//
// Schemas
// -------------------------------------------------------------------------------------------
/** @param definition An object that describes the definition of Schema. */
Schema: <D extends SchemaDefinition>(definition: D): TypeGuard<Schema<D>> =>
constructor(
obj => validateObject(obj, definition),
obj => validateObject(obj as PlainObject, definition),
obj => transformObject(obj, definition),
{ definition }
),
Expand All @@ -333,9 +387,9 @@ export const TypeGuards = {
Partial: <S extends SchemaDefinition | TypeGuard<Schema<SchemaDefinition>>>(
schema: S
): TypeGuard<Partial<Schema<TypeOf<S>>>> => {
const definition = isTypeGuard(schema) ? <SchemaDefinition>schema[DEF] : schema
const definition = isSchemaTypeGuard(schema) ? <SchemaDefinition>schema[DEF] : schema
return constructor(
obj => validateObject(obj, definition, { partial: true }),
obj => validateObject(obj as PlainObject, definition, { partial: true }),
obj => transformObject(obj, definition),
{ definition }
)
Expand All @@ -347,16 +401,22 @@ export const TypeGuards = {
Required: <S extends SchemaDefinition | TypeGuard<Schema<SchemaDefinition>>>(
schema: S
): TypeGuard<Required<Schema<TypeOf<S>>>> => {
const definition = isTypeGuard(schema) ? <SchemaDefinition>schema[DEF] : schema
const definition = isSchemaTypeGuard(schema) ? <SchemaDefinition>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<Schema<SchemaDefinition>>,
Expand All @@ -365,14 +425,14 @@ export const TypeGuards = {
schema: S,
keys: K[]
): TypeGuard<Pick<Schema<TypeOf<S>>, typeof keys[number]>> => {
const definition = isTypeGuard(schema) ? <SchemaDefinition>schema[DEF] : schema
const definition = isSchemaTypeGuard(schema) ? <SchemaDefinition>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 }
)
Expand Down Expand Up @@ -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: <T extends TypeDefinition[]>(...types: T): TypeGuard<TypeOf<typeof types[number]>> =>
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
// -------------------------------------------------------------------------------------------
Expand Down

0 comments on commit 77898d5

Please sign in to comment.