Skip to content

Commit

Permalink
Exact Optional Property Types (#352)
Browse files Browse the repository at this point in the history
  • Loading branch information
sinclairzx81 authored Mar 24, 2023
1 parent 4ae9125 commit 39afc1f
Show file tree
Hide file tree
Showing 8 changed files with 109 additions and 46 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sinclair/typebox",
"version": "0.26.2",
"version": "0.26.3",
"description": "JSONSchema Type Builder with Static Type Resolution for TypeScript",
"keywords": [
"typescript",
Expand Down
31 changes: 17 additions & 14 deletions src/compiler/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,10 @@ export namespace TypeCompiler {
return typeof value === 'string'
}
// -------------------------------------------------------------------
// Overrides
// Polices
// -------------------------------------------------------------------
function IsNumberCheck(value: string): string {
return !TypeSystem.AllowNaN ? `(typeof ${value} === 'number' && Number.isFinite(${value}))` : `typeof ${value} === 'number'`
function IsExactOptionalProperty(value: string, key: string, expression: string) {
return TypeSystem.ExactOptionalPropertyTypes ? `('${key}' in ${value} ? ${expression} : true)` : `(${value}.${key} !== undefined ? ${expression} : true)`
}
function IsObjectCheck(value: string): string {
return !TypeSystem.AllowArrayObjects ? `(typeof ${value} === 'object' && ${value} !== null && !Array.isArray(${value}))` : `(typeof ${value} === 'object' && ${value} !== null)`
Expand All @@ -157,6 +157,9 @@ export namespace TypeCompiler {
? `(typeof ${value} === 'object' && ${value} !== null && !Array.isArray(${value}) && !(${value} instanceof Date) && !(${value} instanceof Uint8Array))`
: `(typeof ${value} === 'object' && ${value} !== null && !(${value} instanceof Date) && !(${value} instanceof Uint8Array))`
}
function IsNumberCheck(value: string): string {
return !TypeSystem.AllowNaN ? `(typeof ${value} === 'number' && Number.isFinite(${value}))` : `typeof ${value} === 'number'`
}
function IsVoidCheck(value: string): string {
return TypeSystem.AllowVoidNull ? `(${value} === undefined || ${value} === null)` : `${value} === undefined`
}
Expand Down Expand Up @@ -255,29 +258,29 @@ export namespace TypeCompiler {
yield IsObjectCheck(value)
if (IsNumber(schema.minProperties)) yield `Object.getOwnPropertyNames(${value}).length >= ${schema.minProperties}`
if (IsNumber(schema.maxProperties)) yield `Object.getOwnPropertyNames(${value}).length <= ${schema.maxProperties}`
const schemaKeys = globalThis.Object.getOwnPropertyNames(schema.properties)
for (const schemaKey of schemaKeys) {
const memberExpression = MemberExpression.Encode(value, schemaKey)
const property = schema.properties[schemaKey]
if (schema.required && schema.required.includes(schemaKey)) {
const knownKeys = globalThis.Object.getOwnPropertyNames(schema.properties)
for (const knownKey of knownKeys) {
const memberExpression = MemberExpression.Encode(value, knownKey)
const property = schema.properties[knownKey]
if (schema.required && schema.required.includes(knownKey)) {
yield* Visit(property, references, memberExpression)
if (Types.ExtendsUndefined.Check(property)) yield `('${schemaKey}' in ${value})`
if (Types.ExtendsUndefined.Check(property)) yield `('${knownKey}' in ${value})`
} else {
const expression = CreateExpression(property, references, memberExpression)
yield `('${schemaKey}' in ${value} ? ${expression} : true)`
yield IsExactOptionalProperty(value, knownKey, expression)
}
}
if (schema.additionalProperties === false) {
if (schema.required && schema.required.length === schemaKeys.length) {
yield `Object.getOwnPropertyNames(${value}).length === ${schemaKeys.length}`
if (schema.required && schema.required.length === knownKeys.length) {
yield `Object.getOwnPropertyNames(${value}).length === ${knownKeys.length}`
} else {
const keys = `[${schemaKeys.map((key) => `'${key}'`).join(', ')}]`
const keys = `[${knownKeys.map((key) => `'${key}'`).join(', ')}]`
yield `Object.getOwnPropertyNames(${value}).every(key => ${keys}.includes(key))`
}
}
if (typeof schema.additionalProperties === 'object') {
const expression = CreateExpression(schema.additionalProperties, references, 'value[key]')
const keys = `[${schemaKeys.map((key) => `'${key}'`).join(', ')}]`
const keys = `[${knownKeys.map((key) => `'${key}'`).join(', ')}]`
yield `(Object.getOwnPropertyNames(${value}).every(key => ${keys}.includes(key) || ${expression}))`
}
}
Expand Down
33 changes: 20 additions & 13 deletions src/errors/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,33 +135,40 @@ export namespace ValueErrors {
// ----------------------------------------------------------------------
// Guards
// ----------------------------------------------------------------------
function IsBigInt(value: unknown): value is bigint {
return typeof value === 'bigint'
}
function IsInteger(value: unknown): value is number {
return globalThis.Number.isInteger(value)
}
function IsString(value: unknown): value is string {
return typeof value === 'string'
}
function IsDefined<T>(value: unknown): value is T {
return value !== undefined
}
// ----------------------------------------------------------------------
// Policies
// ----------------------------------------------------------------------
function IsExactOptionalProperty(value: Record<keyof any, unknown>, key: string) {
return TypeSystem.ExactOptionalPropertyTypes ? key in value : value[key] !== undefined
}
function IsObject(value: unknown): value is Record<keyof any, unknown> {
const result = typeof value === 'object' && value !== null
return TypeSystem.AllowArrayObjects ? result : result && !globalThis.Array.isArray(value)
}
function IsRecordObject(value: unknown): value is Record<keyof any, unknown> {
return IsObject(value) && !(value instanceof globalThis.Date) && !(value instanceof globalThis.Uint8Array)
}
function IsBigInt(value: unknown): value is bigint {
return typeof value === 'bigint'
}
function IsNumber(value: unknown): value is number {
const result = typeof value === 'number'
return TypeSystem.AllowNaN ? result : result && globalThis.Number.isFinite(value)
}
function IsInteger(value: unknown): value is number {
return globalThis.Number.isInteger(value)
}
function IsString(value: unknown): value is string {
return typeof value === 'string'
}
function IsVoid(value: unknown): value is void {
const result = value === undefined
return TypeSystem.AllowVoidNull ? result || value === null : result
}
function IsDefined<T>(value: unknown): value is T {
return value !== undefined
}

// ----------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------
Expand Down Expand Up @@ -351,7 +358,7 @@ export namespace ValueErrors {
yield { type: ValueErrorType.ObjectRequiredProperties, schema: property, path: `${path}/${knownKey}`, value: undefined, message: `Expected required property` }
}
} else {
if (knownKey in value) {
if (IsExactOptionalProperty(value, knownKey)) {
yield* Visit(property, references, `${path}/${knownKey}`, value[knownKey])
}
}
Expand Down
11 changes: 11 additions & 0 deletions src/system/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,24 @@ export class TypeSystemDuplicateFormat extends Error {
super(`Duplicate string format '${kind}' detected`)
}
}

/** Creates user defined types and formats and provides overrides for value checking behaviours */
export namespace TypeSystem {
// ------------------------------------------------------------------------
// Assertion Policies
// ------------------------------------------------------------------------
/** Sets whether TypeBox should assert optional properties using the TypeScript `exactOptionalPropertyTypes` assertion policy. The default is `false` */
export let ExactOptionalPropertyTypes: boolean = false
/** Sets whether arrays should be treated as a kind of objects. The default is `false` */
export let AllowArrayObjects: boolean = false
/** Sets whether `NaN` or `Infinity` should be treated as valid numeric values. The default is `false` */
export let AllowNaN: boolean = false
/** Sets whether `null` should validate for void types. The default is `false` */
export let AllowVoidNull: boolean = false

// ------------------------------------------------------------------------
// String Formats and Types
// ------------------------------------------------------------------------
/** Creates a new type */
export function Type<Type, Options = object>(kind: string, check: (options: Options, value: unknown) => boolean) {
if (Types.TypeRegistry.Has(kind)) throw new TypeSystemDuplicateTypeKind(kind)
Expand All @@ -58,6 +68,7 @@ export namespace TypeSystem {
Types.FormatRegistry.Set(format, check)
return format
}

// ------------------------------------------------------------------------
// Deprecated
// ------------------------------------------------------------------------
Expand Down
32 changes: 19 additions & 13 deletions src/value/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,33 +47,39 @@ export namespace ValueCheck {
// ----------------------------------------------------------------------
// Guards
// ----------------------------------------------------------------------
function IsBigInt(value: unknown): value is bigint {
return typeof value === 'bigint'
}
function IsInteger(value: unknown): value is number {
return globalThis.Number.isInteger(value)
}
function IsString(value: unknown): value is string {
return typeof value === 'string'
}
function IsDefined<T>(value: unknown): value is T {
return value !== undefined
}
// ----------------------------------------------------------------------
// Policies
// ----------------------------------------------------------------------
function IsExactOptionalProperty(value: Record<keyof any, unknown>, key: string) {
return TypeSystem.ExactOptionalPropertyTypes ? key in value : value[key] !== undefined
}
function IsObject(value: unknown): value is Record<keyof any, unknown> {
const result = typeof value === 'object' && value !== null
return TypeSystem.AllowArrayObjects ? result : result && !globalThis.Array.isArray(value)
}
function IsRecordObject(value: unknown): value is Record<keyof any, unknown> {
return IsObject(value) && !(value instanceof globalThis.Date) && !(value instanceof globalThis.Uint8Array)
}
function IsBigInt(value: unknown): value is bigint {
return typeof value === 'bigint'
}
function IsNumber(value: unknown): value is number {
const result = typeof value === 'number'
return TypeSystem.AllowNaN ? result : result && globalThis.Number.isFinite(value)
}
function IsInteger(value: unknown): value is number {
return globalThis.Number.isInteger(value)
}
function IsString(value: unknown): value is string {
return typeof value === 'string'
}
function IsVoid(value: unknown): value is void {
const result = value === undefined
return TypeSystem.AllowVoidNull ? result || value === null : result
}
function IsDefined<T>(value: unknown): value is T {
return value !== undefined
}
// ----------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------
Expand Down Expand Up @@ -237,7 +243,7 @@ export namespace ValueCheck {
return knownKey in value
}
} else {
if (knownKey in value && !Visit(property, references, value[knownKey])) {
if (IsExactOptionalProperty(value, knownKey) && !Visit(property, references, value[knownKey])) {
return false
}
}
Expand Down
6 changes: 3 additions & 3 deletions test/runtime/compiler/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,16 +227,16 @@ describe('type/compiler/Object', () => {
Ok(T, { x: undefined })
Ok(T, {})
})
it('Should not check undefined for optional property of number', () => {
it('Should check undefined for optional property of number', () => {
const T = Type.Object({ x: Type.Optional(Type.Number()) })
Ok(T, { x: 1 })
Ok(T, { x: undefined }) // allowed by default
Ok(T, {})
Fail(T, { x: undefined })
})
it('Should check undefined for optional property of undefined', () => {
const T = Type.Object({ x: Type.Optional(Type.Undefined()) })
Fail(T, { x: 1 })
Ok(T, {})
Ok(T, { x: undefined })
Ok(T, {})
})
})
36 changes: 36 additions & 0 deletions test/runtime/system/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,42 @@ import { Assert } from '../assert/index'
import { TypeSystem } from '@sinclair/typebox/system'
import { Type } from '@sinclair/typebox'

describe('system/TypeSystem/ExactOptionalPropertyTypes', () => {
before(() => {
TypeSystem.ExactOptionalPropertyTypes = true
})
after(() => {
TypeSystem.ExactOptionalPropertyTypes = false
})
// ---------------------------------------------------------------
// Number
// ---------------------------------------------------------------
it('Should not validate optional number', () => {
const T = Type.Object({
x: Type.Optional(Type.Number()),
})
Ok(T, {})
Ok(T, { x: 1 })
Fail(T, { x: undefined })
})
it('Should not validate undefined', () => {
const T = Type.Object({
x: Type.Optional(Type.Undefined()),
})
Ok(T, {})
Fail(T, { x: 1 })
Ok(T, { x: undefined })
})
it('Should validate optional number | undefined', () => {
const T = Type.Object({
x: Type.Optional(Type.Union([Type.Number(), Type.Undefined()])),
})
Ok(T, {})
Ok(T, { x: 1 })
Ok(T, { x: undefined })
})
})

describe('system/TypeSystem/AllowNaN', () => {
before(() => {
TypeSystem.AllowNaN = true
Expand Down
4 changes: 2 additions & 2 deletions test/runtime/value/check/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,11 @@ describe('value/check/Object', () => {
Assert.equal(Value.Check(T, { x: undefined }), true)
Assert.equal(Value.Check(T, {}), true)
})
it('Should not check undefined for optional property of number', () => {
it('Should check undefined for optional property of number', () => {
const T = Type.Object({ x: Type.Optional(Type.Number()) })
Assert.equal(Value.Check(T, { x: 1 }), true)
Assert.equal(Value.Check(T, { x: undefined }), true) // allowed by default
Assert.equal(Value.Check(T, {}), true)
Assert.equal(Value.Check(T, { x: undefined }), false)
})
it('Should check undefined for optional property of undefined', () => {
const T = Type.Object({ x: Type.Optional(Type.Undefined()) })
Expand Down

0 comments on commit 39afc1f

Please sign in to comment.