diff --git a/src/system/memory/discard.ts b/src/system/memory/discard.ts index a4862b516..912a8fe33 100644 --- a/src/system/memory/discard.ts +++ b/src/system/memory/discard.ts @@ -30,14 +30,16 @@ THE SOFTWARE. import { Metrics } from "./metrics.ts" import { Clone } from './clone.ts' +import { IsBase } from '../../type/types/base.ts' type ObjectLike = Record /** Discards multiple property keys from the given object value */ export function Discard(value: ObjectLike, propertyKeys: PropertyKey[]): ObjectLike { Metrics.discard += 1 - const result: ObjectLike = {} - const descriptors = Object.getOwnPropertyDescriptors(Clone(value)) + const clone = Clone(value) + const result: ObjectLike = IsBase(value) ? clone : {} + const descriptors = Object.getOwnPropertyDescriptors(clone) const keysToDiscard = new Set(propertyKeys) for (const key of Object.keys(descriptors)) { if (keysToDiscard.has(key)) continue diff --git a/src/type/extends/base.ts b/src/type/extends/base.ts new file mode 100644 index 000000000..4eda854a2 --- /dev/null +++ b/src/type/extends/base.ts @@ -0,0 +1,72 @@ +/*-------------------------------------------------------------------------- + +TypeBox + +The MIT License (MIT) + +Copyright (c) 2017-2025 Haydn Paterson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +---------------------------------------------------------------------------*/ + +// deno-fmt-ignore-file + +import { type TSchema } from '../types/schema.ts' +import { type TProperties } from '../types/properties.ts' +import { type Base, IsBase } from '../types/base.ts' +import { type TExtendsRight, ExtendsRight } from './extends-right.ts' + +import * as Result from './result.ts' + +// ------------------------------------------------------------------ +// ExtendsBase +// +// Two Base instances extend each other if they are instances of +// the same class (i.e., they have the same constructor). This +// models TypeScript's structural type checking at runtime for +// custom Base types. +// +// Note: At the type level, TypeScript cannot determine if two Base +// instances have the same constructor, so we check if they're +// mutually assignable (both ways) which indicates they're likely +// the same type. +// ------------------------------------------------------------------ +export type TExtendsBase = ( + Right extends Base + ? [Left] extends [Right] + ? [Right] extends [Left] + ? Result.TExtendsTrue + : Result.TExtendsFalse + : Result.TExtendsFalse + : TExtendsRight +) + +export function ExtendsBase + (inferred: Inferred, left: Left, right: Right): + TExtendsBase { + return ( + IsBase(right) + // Use Equals method for checking equality between Base instances + ? (left.Equals(right) + ? Result.ExtendsTrue(inferred) + : Result.ExtendsFalse()) + : ExtendsRight(inferred, left, right) + ) as never +} diff --git a/src/type/extends/extends-left.ts b/src/type/extends/extends-left.ts index 4d57a2fa4..b191b92bf 100644 --- a/src/type/extends/extends-left.ts +++ b/src/type/extends/extends-left.ts @@ -31,6 +31,7 @@ THE SOFTWARE. import { type TExtendsAny, ExtendsAny } from './any.ts' import { type TExtendsArray, ExtendsArray } from './array.ts' import { type TExtendsAsyncIterator, ExtendsAsyncIterator } from './async-iterator.ts' +import { type TExtendsBase, ExtendsBase } from './base.ts' import { type TExtendsBigInt, ExtendsBigInt } from './bigint.ts' import { type TExtendsBoolean, ExtendsBoolean } from './boolean.ts' import { type TExtendsConstructor, ExtendsConstructor } from './constructor.ts' @@ -60,6 +61,7 @@ import { type TExtendsVoid, ExtendsVoid } from './void.ts' import { type TAny, IsAny } from '../types/any.ts' import { type TArray, IsArray } from '../types/array.ts' import { type TAsyncIterator, IsAsyncIterator } from '../types/async-iterator.ts' +import { type Base, IsBase } from '../types/base.ts' import { type TBigInt, IsBigInt } from '../types/bigint.ts' import { type TBoolean, IsBoolean } from '../types/boolean.ts' import { type TConstructor, IsConstructor } from '../types/constructor.ts' @@ -94,6 +96,7 @@ export type TExtendsLeft : Left extends TArray ? TExtendsArray : Left extends TAsyncIterator ? TExtendsAsyncIterator : + Left extends Base ? TExtendsBase : Left extends TBigInt ? TExtendsBigInt : Left extends TBoolean ? TExtendsBoolean : Left extends TConstructor ? TExtendsConstructor : @@ -125,6 +128,7 @@ export function ExtendsLeft return Result.ExtendsTrue(inferred) } // ---------------------------------------------------------------------------- +// ExtendsRightBase +// ---------------------------------------------------------------------------- +type TExtendsRightBase = ( + Left extends Base + ? Left extends Right + ? Result.TExtendsTrue + : Result.TExtendsFalse + : Result.TExtendsFalse +) +function ExtendsRightBase + (inferred: Inferred, left: Left, right: Right): + TExtendsRightBase { + return ( + IsBase(left) + // Use Equals method for checking equality between Base instances + ? (right.Equals(left) + ? Result.ExtendsTrue(inferred) + : Result.ExtendsFalse()) + : Result.ExtendsFalse() + ) as never +} +// ---------------------------------------------------------------------------- // ExtendsRightEnum // ---------------------------------------------------------------------------- type TExtendsRightEnum = ( Right extends TAny ? TExtendsRightAny : + Right extends Base ? TExtendsRightBase : Right extends TEnum ? TExtendsRightEnum : Right extends TInfer ? TExtendsRightInfer : Right extends TTemplateLiteral ? TExtendsRightTemplateLiteral : @@ -172,6 +196,7 @@ export function ExtendsRight { return ( IsAny(right) ? ExtendsRightAny(inferred, left) : + IsBase(right) ? ExtendsRightBase(inferred, left, right) : IsEnum(right) ? ExtendsRightEnum(inferred, left, right.enum) : IsInfer(right) ? ExtendsRightInfer(inferred, right.name, left, right.extends) : IsIntersect(right) ? ExtendsRightIntersect(inferred, left, right.allOf) : diff --git a/src/type/types/base.ts b/src/type/types/base.ts index 5f9ca3bee..aefeb2600 100644 --- a/src/type/types/base.ts +++ b/src/type/types/base.ts @@ -92,6 +92,15 @@ export class Base implements TSchema, XGuard { Type.Object({ value: Type.Optional(new Foo()) }) ])) Assert.IsTrue(Type.IsOptional(T.properties.value)) - Assert.IsTrue(Type.IsNever(T.properties.value)) + Assert.IsTrue(Type.IsBase(T.properties.value)) + Assert.IsTrue(T.properties.value instanceof Foo) }) Test('Should Evaluate 65', () => { const T = Type.Evaluate(Type.Intersect([ @@ -814,7 +815,8 @@ Test('Should Evaluate 65', () => { Type.Object({ value: new Foo() }) ])) Assert.IsFalse(Type.IsOptional(T.properties.value)) - Assert.IsTrue(Type.IsNever(T.properties.value)) + Assert.IsTrue(Type.IsBase(T.properties.value)) + Assert.IsTrue(T.properties.value instanceof Foo) }) Test('Should Evaluate 66', () => { const T = Type.Evaluate(Type.Intersect([ @@ -825,3 +827,37 @@ Test('Should Evaluate 66', () => { Assert.IsTrue(Type.IsBase(T.properties.value)) Assert.IsTrue(T.properties.value instanceof Foo) }) +class Bar extends Type.Base { + public constructor(private kind: T) { + super(); + } + public override Check(value: unknown): value is unknown { + return true + } + public override Errors(value: unknown): object[] { + return [] + } + public override Clone(): Bar { + return new Bar(this.kind) + } + public override Equals(other: unknown): other is this { + return other instanceof Bar && other.kind === this.kind + } +} +Test('Should Evaluate 67', () => { + const T = Type.Evaluate(Type.Intersect([ + Type.Object({ value: Type.Optional(new Bar('1')) }), + Type.Object({ value: Type.Optional(new Bar('1')) }) + ])) + Assert.IsTrue(Type.IsOptional(T.properties.value)) + Assert.IsTrue(Type.IsBase(T.properties.value)) + Assert.IsTrue(T.properties.value instanceof Bar) +}) +Test('Should Evaluate 68', () => { + const T = Type.Evaluate(Type.Intersect([ + Type.Object({ value: Type.Optional(new Bar('1')) }), + Type.Object({ value: Type.Optional(new Bar('2')) }) + ])) + Assert.IsTrue(Type.IsOptional(T.properties.value)) + Assert.IsTrue(Type.IsNever(T.properties.value)) +}) \ No newline at end of file diff --git a/test/typebox/runtime/type/extends/base.ts b/test/typebox/runtime/type/extends/base.ts index 93448e1c1..640eac019 100644 --- a/test/typebox/runtime/type/extends/base.ts +++ b/test/typebox/runtime/type/extends/base.ts @@ -14,15 +14,13 @@ export class TBase extends Type.Base { const Base = new TBase() // ------------------------------------------------------------------ -// Identity: Note, it's not obvious to how to handle Base checks -// without relying on nominal typing, while technically possible -// to check for the constructor, bundlers and downlevel targets -// make this a unreliable check. For now, Base is considered a -// distinct type of itself. +// Identity: Base types extend each other if they are instances +// of the same class (constructor check). This models TypeScript's +// structural type checking at runtime. // ------------------------------------------------------------------ Test('Should Extends 0', () => { - const R: ExtendsResult.TExtendsFalse = Extends({}, Base, Base) - Assert.IsTrue(ExtendsResult.IsExtendsFalse(R)) + const R: ExtendsResult.TExtendsTrue<{}> = Extends({}, Base, Base) + Assert.IsTrue(ExtendsResult.IsExtendsTrue(R)) }) // ------------------------------------------------------------------ // Invariant diff --git a/test/typebox/runtime/type/types/base-evaluate.ts b/test/typebox/runtime/type/types/base-evaluate.ts new file mode 100644 index 000000000..331c70625 --- /dev/null +++ b/test/typebox/runtime/type/types/base-evaluate.ts @@ -0,0 +1,128 @@ +import { Assert } from 'test' +import * as Type from 'typebox' + +const Test = Assert.Context('Type.Base.Evaluate') + +// ------------------------------------------------------------------ +// Base Type Intersection with Evaluate +// ------------------------------------------------------------------ +class TId extends Type.Base { + public override Check(value: unknown): value is string { + return typeof value === 'string' + } + public override Clone(): TId { + return new TId() + } +} + +Test('Should evaluate intersect of same Base class instances as equal 1', () => { + const schemaNonEvaluated = Type.Intersect([ + new TId(), + new TId(), + ]) + const schemaEvaluated = Type.Evaluate(schemaNonEvaluated) + + // Should not be never + Assert.IsFalse(Type.IsNever(schemaEvaluated)) + // Should be TId (Base type) + Assert.IsTrue(Type.IsBase(schemaEvaluated)) +}) + +Test('Should evaluate intersect of same Base class instances as equal 2', () => { + const T1 = new TId() + const T2 = new TId() + const schemaNonEvaluated = Type.Intersect([T1, T2]) + const schemaEvaluated = Type.Evaluate(schemaNonEvaluated) + + // Should not be never + Assert.IsFalse(Type.IsNever(schemaEvaluated)) + // Should be TId (Base type) + Assert.IsTrue(Type.IsBase(schemaEvaluated)) + // Should be instance of TId + Assert.IsTrue(schemaEvaluated instanceof TId) +}) + +Test('Should evaluate intersect of different Base class instances as never', () => { + class TId2 extends Type.Base { + public override Check(value: unknown): value is number { + return typeof value === 'number' + } + public override Clone(): TId2 { + return new TId2() + } + } + + const schemaNonEvaluated = Type.Intersect([ + new TId(), + new TId2(), + ]) + const schemaEvaluated = Type.Evaluate(schemaNonEvaluated) + + // Should be never + Assert.IsTrue(Type.IsNever(schemaEvaluated)) +}) + +Test('Should evaluate single Base type in intersect', () => { + const schemaNonEvaluated = Type.Intersect([ + new TId(), + ]) + const schemaEvaluated = Type.Evaluate(schemaNonEvaluated) + + // Should not be never + Assert.IsFalse(Type.IsNever(schemaEvaluated)) + // Should be TId (Base type) + Assert.IsTrue(Type.IsBase(schemaEvaluated)) + // Should be instance of TId + Assert.IsTrue(schemaEvaluated instanceof TId) +}) + +Test('Should evaluate intersect of Base type with Object', () => { + const schemaNonEvaluated = Type.Intersect([ + new TId(), + Type.Object({ foo: Type.String() }), + ]) + const schemaEvaluated = Type.Evaluate(schemaNonEvaluated) + + // Should be an object with foo property + Assert.IsTrue(Type.IsObject(schemaEvaluated)) + Assert.IsTrue('foo' in schemaEvaluated.properties) + Assert.IsTrue(Type.IsString(schemaEvaluated.properties.foo)) +}) + +Test('Should evaluate triple intersect of same Base class instances', () => { + const schemaNonEvaluated = Type.Intersect([ + new TId(), + new TId(), + new TId(), + ]) + const schemaEvaluated = Type.Evaluate(schemaNonEvaluated) + + // Should not be never + Assert.IsFalse(Type.IsNever(schemaEvaluated)) + // Should be TId (Base type) + Assert.IsTrue(Type.IsBase(schemaEvaluated)) + // Should be instance of TId + Assert.IsTrue(schemaEvaluated instanceof TId) +}) + +// Type-level check (from the original issue) +Test('Should preserve type-level types correctly', () => { + const schemaNonEvaluated = Type.Intersect([ + new TId(), + new TId(), + ]) + type TNonEvaluated = Type.StaticDecode + + const schemaEvaluated = Type.Evaluate(schemaNonEvaluated) + type TEvaluated = Type.StaticDecode + + // Runtime checks + Assert.IsFalse(Type.IsNever(schemaEvaluated)) + Assert.IsTrue(Type.IsBase(schemaEvaluated)) + + // Type-level assertions (compile-time checks) + const _nonEval: TNonEvaluated = 'test' // should be string + const _eval: TEvaluated = 'test' // should be string, not never + + Assert.IsTrue(true) // If compiles, test passes +})