diff --git a/package.json b/package.json index 9160c0b..dfeab5f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 30a15e5..aee1fa2 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -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)` @@ -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` } @@ -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}))` } } diff --git a/src/errors/errors.ts b/src/errors/errors.ts index 780b6d7..51476a6 100644 --- a/src/errors/errors.ts +++ b/src/errors/errors.ts @@ -135,33 +135,40 @@ export namespace ValueErrors { // ---------------------------------------------------------------------- // Guards // ---------------------------------------------------------------------- - function IsObject(value: unknown): value is Record { - const result = typeof value === 'object' && value !== null - return TypeSystem.AllowArrayObjects ? result : result && !globalThis.Array.isArray(value) - } - function IsRecordObject(value: unknown): value is Record { - 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 IsDefined(value: unknown): value is T { + return value !== undefined + } + // ---------------------------------------------------------------------- + // Policies + // ---------------------------------------------------------------------- + function IsExactOptionalProperty(value: Record, key: string) { + return TypeSystem.ExactOptionalPropertyTypes ? key in value : value[key] !== undefined + } + function IsObject(value: unknown): value is Record { + const result = typeof value === 'object' && value !== null + return TypeSystem.AllowArrayObjects ? result : result && !globalThis.Array.isArray(value) + } + function IsRecordObject(value: unknown): value is Record { + return IsObject(value) && !(value instanceof globalThis.Date) && !(value instanceof globalThis.Uint8Array) + } + function IsNumber(value: unknown): value is number { + const result = typeof value === 'number' + return TypeSystem.AllowNaN ? result : result && globalThis.Number.isFinite(value) + } function IsVoid(value: unknown): value is void { const result = value === undefined return TypeSystem.AllowVoidNull ? result || value === null : result } - function IsDefined(value: unknown): value is T { - return value !== undefined - } + // ---------------------------------------------------------------------- // Types // ---------------------------------------------------------------------- @@ -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]) } } diff --git a/src/system/system.ts b/src/system/system.ts index 598f6cc..a7dda58 100644 --- a/src/system/system.ts +++ b/src/system/system.ts @@ -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(kind: string, check: (options: Options, value: unknown) => boolean) { if (Types.TypeRegistry.Has(kind)) throw new TypeSystemDuplicateTypeKind(kind) @@ -58,6 +68,7 @@ export namespace TypeSystem { Types.FormatRegistry.Set(format, check) return format } + // ------------------------------------------------------------------------ // Deprecated // ------------------------------------------------------------------------ diff --git a/src/value/check.ts b/src/value/check.ts index 59db8e2..c30e406 100644 --- a/src/value/check.ts +++ b/src/value/check.ts @@ -47,33 +47,39 @@ export namespace ValueCheck { // ---------------------------------------------------------------------- // Guards // ---------------------------------------------------------------------- - function IsObject(value: unknown): value is Record { - const result = typeof value === 'object' && value !== null - return TypeSystem.AllowArrayObjects ? result : result && !globalThis.Array.isArray(value) - } - function IsRecordObject(value: unknown): value is Record { - 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 IsDefined(value: unknown): value is T { + return value !== undefined + } + // ---------------------------------------------------------------------- + // Policies + // ---------------------------------------------------------------------- + function IsExactOptionalProperty(value: Record, key: string) { + return TypeSystem.ExactOptionalPropertyTypes ? key in value : value[key] !== undefined + } + function IsObject(value: unknown): value is Record { + const result = typeof value === 'object' && value !== null + return TypeSystem.AllowArrayObjects ? result : result && !globalThis.Array.isArray(value) + } + function IsRecordObject(value: unknown): value is Record { + return IsObject(value) && !(value instanceof globalThis.Date) && !(value instanceof globalThis.Uint8Array) + } + function IsNumber(value: unknown): value is number { + const result = typeof value === 'number' + return TypeSystem.AllowNaN ? result : result && globalThis.Number.isFinite(value) + } function IsVoid(value: unknown): value is void { const result = value === undefined return TypeSystem.AllowVoidNull ? result || value === null : result } - function IsDefined(value: unknown): value is T { - return value !== undefined - } // ---------------------------------------------------------------------- // Types // ---------------------------------------------------------------------- @@ -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 } } diff --git a/test/runtime/compiler/object.ts b/test/runtime/compiler/object.ts index 699b533..e6faf08 100644 --- a/test/runtime/compiler/object.ts +++ b/test/runtime/compiler/object.ts @@ -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, {}) }) }) diff --git a/test/runtime/system/system.ts b/test/runtime/system/system.ts index 5c41eda..dc7c51d 100644 --- a/test/runtime/system/system.ts +++ b/test/runtime/system/system.ts @@ -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 diff --git a/test/runtime/value/check/object.ts b/test/runtime/value/check/object.ts index 6b672b9..6a06962 100644 --- a/test/runtime/value/check/object.ts +++ b/test/runtime/value/check/object.ts @@ -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()) })