Skip to content

Commit eda6a99

Browse files
committed
fix: EType.Evaluate respects Type.Base during Type.Intersect
1 parent 0f7fd7d commit eda6a99

File tree

8 files changed

+251
-11
lines changed

8 files changed

+251
-11
lines changed

src/system/memory/discard.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,16 @@ THE SOFTWARE.
3030

3131
import { Metrics } from "./metrics.ts"
3232
import { Clone } from './clone.ts'
33+
import Type from 'typebox'
3334

3435
type ObjectLike = Record<PropertyKey, any>
3536

3637
/** Discards multiple property keys from the given object value */
3738
export function Discard(value: ObjectLike, propertyKeys: PropertyKey[]): ObjectLike {
3839
Metrics.discard += 1
39-
const result: ObjectLike = {}
40-
const descriptors = Object.getOwnPropertyDescriptors(Clone(value))
40+
const clone = Clone(value)
41+
const result: ObjectLike = Type.IsBase(value) ? clone : {}
42+
const descriptors = Object.getOwnPropertyDescriptors(clone)
4143
const keysToDiscard = new Set(propertyKeys)
4244
for (const key of Object.keys(descriptors)) {
4345
if (keysToDiscard.has(key)) continue

src/type/extends/base.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*--------------------------------------------------------------------------
2+
3+
TypeBox
4+
5+
The MIT License (MIT)
6+
7+
Copyright (c) 2017-2025 Haydn Paterson
8+
9+
Permission is hereby granted, free of charge, to any person obtaining a copy
10+
of this software and associated documentation files (the "Software"), to deal
11+
in the Software without restriction, including without limitation the rights
12+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13+
copies of the Software, and to permit persons to whom the Software is
14+
furnished to do so, subject to the following conditions:
15+
16+
The above copyright notice and this permission notice shall be included in
17+
all copies or substantial portions of the Software.
18+
19+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
THE SOFTWARE.
26+
27+
---------------------------------------------------------------------------*/
28+
29+
// deno-fmt-ignore-file
30+
31+
import { type TSchema } from '../types/schema.ts'
32+
import { type TProperties } from '../types/properties.ts'
33+
import { type Base, IsBase } from '../types/base.ts'
34+
import { type TExtendsRight, ExtendsRight } from './extends-right.ts'
35+
36+
import * as Result from './result.ts'
37+
38+
// ------------------------------------------------------------------
39+
// ExtendsBase
40+
//
41+
// Two Base instances extend each other if they are instances of
42+
// the same class (i.e., they have the same constructor). This
43+
// models TypeScript's structural type checking at runtime for
44+
// custom Base types.
45+
//
46+
// Note: At the type level, TypeScript cannot determine if two Base
47+
// instances have the same constructor, so we check if they're
48+
// mutually assignable (both ways) which indicates they're likely
49+
// the same type.
50+
// ------------------------------------------------------------------
51+
export type TExtendsBase<Inferred extends TProperties, Left extends Base, Right extends TSchema> = (
52+
Right extends Base
53+
? [Left] extends [Right]
54+
? [Right] extends [Left]
55+
? Result.TExtendsTrue<Inferred>
56+
: Result.TExtendsFalse
57+
: Result.TExtendsFalse
58+
: TExtendsRight<Inferred, Left, Right>
59+
)
60+
61+
export function ExtendsBase<Inferred extends TProperties, Left extends Base, Right extends TSchema>
62+
(inferred: Inferred, left: Left, right: Right):
63+
TExtendsBase<Inferred, Left, Right> {
64+
return (
65+
IsBase(right)
66+
// Use Equals method for checking equality between Base instances
67+
? (left.Equals(right)
68+
? Result.ExtendsTrue(inferred)
69+
: Result.ExtendsFalse())
70+
: ExtendsRight(inferred, left, right)
71+
) as never
72+
}

src/type/extends/extends-left.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ THE SOFTWARE.
3131
import { type TExtendsAny, ExtendsAny } from './any.ts'
3232
import { type TExtendsArray, ExtendsArray } from './array.ts'
3333
import { type TExtendsAsyncIterator, ExtendsAsyncIterator } from './async-iterator.ts'
34+
import { type TExtendsBase, ExtendsBase } from './base.ts'
3435
import { type TExtendsBigInt, ExtendsBigInt } from './bigint.ts'
3536
import { type TExtendsBoolean, ExtendsBoolean } from './boolean.ts'
3637
import { type TExtendsConstructor, ExtendsConstructor } from './constructor.ts'
@@ -60,6 +61,7 @@ import { type TExtendsVoid, ExtendsVoid } from './void.ts'
6061
import { type TAny, IsAny } from '../types/any.ts'
6162
import { type TArray, IsArray } from '../types/array.ts'
6263
import { type TAsyncIterator, IsAsyncIterator } from '../types/async-iterator.ts'
64+
import { type Base, IsBase } from '../types/base.ts'
6365
import { type TBigInt, IsBigInt } from '../types/bigint.ts'
6466
import { type TBoolean, IsBoolean } from '../types/boolean.ts'
6567
import { type TConstructor, IsConstructor } from '../types/constructor.ts'
@@ -94,6 +96,7 @@ export type TExtendsLeft<Inferred extends TProperties, Left extends TSchema, Rig
9496
Left extends TAny ? TExtendsAny<Inferred, Left, Right> :
9597
Left extends TArray<infer Items extends TSchema> ? TExtendsArray<Inferred, Left, Items, Right> :
9698
Left extends TAsyncIterator<infer Type extends TSchema> ? TExtendsAsyncIterator<Inferred, Type, Right> :
99+
Left extends Base ? TExtendsBase<Inferred, Left, Right> :
97100
Left extends TBigInt ? TExtendsBigInt<Inferred, Left, Right> :
98101
Left extends TBoolean ? TExtendsBoolean<Inferred, Left, Right> :
99102
Left extends TConstructor<infer Parameters extends TSchema[], infer InstanceType extends TSchema> ? TExtendsConstructor<Inferred, Parameters, InstanceType, Right> :
@@ -125,6 +128,7 @@ export function ExtendsLeft<Inferred extends TProperties, Left extends TSchema,
125128
IsAny(left) ? ExtendsAny(inferred, left, right) :
126129
IsArray(left) ? ExtendsArray(inferred, left, left.items, right) :
127130
IsAsyncIterator(left) ? ExtendsAsyncIterator(inferred, left.iteratorItems, right) :
131+
IsBase(left) ? ExtendsBase(inferred, left, right) :
128132
IsBigInt(left) ? ExtendsBigInt(inferred, left, right) :
129133
IsBoolean(left) ? ExtendsBoolean(inferred, left, right) :
130134
IsConstructor(left) ? ExtendsConstructor(inferred, left.parameters, left.instanceType, right) :

src/type/extends/extends-right.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { Memory } from '../../system/memory/index.ts'
3232
import { type TSchema, IsSchema } from '../types/schema.ts'
3333
import { type TProperties } from '../types/properties.ts'
3434
import { type TAny, IsAny } from '../types/any.ts'
35+
import { type Base, IsBase } from '../types/base.ts'
3536
import { type TEnum, type TEnumValue, IsEnum } from '../types/enum.ts'
3637
import { type TInfer, IsInfer } from '../types/infer.ts'
3738
import { type TIntersect, IsIntersect } from '../types/intersect.ts'
@@ -85,6 +86,28 @@ function ExtendsRightAny<Inferred extends TProperties, Left extends TSchema>
8586
return Result.ExtendsTrue(inferred)
8687
}
8788
// ----------------------------------------------------------------------------
89+
// ExtendsRightBase
90+
// ----------------------------------------------------------------------------
91+
type TExtendsRightBase<Inferred extends TProperties, Left extends TSchema, Right extends Base> = (
92+
Left extends Base
93+
? Left extends Right
94+
? Result.TExtendsTrue<Inferred>
95+
: Result.TExtendsFalse
96+
: Result.TExtendsFalse
97+
)
98+
function ExtendsRightBase<Inferred extends TProperties, Left extends TSchema, Right extends Base>
99+
(inferred: Inferred, left: Left, right: Right):
100+
TExtendsRightBase<Inferred, Left, Right> {
101+
return (
102+
IsBase(left)
103+
// Use Equals method for checking equality between Base instances
104+
? (right.Equals(left)
105+
? Result.ExtendsTrue(inferred)
106+
: Result.ExtendsFalse())
107+
: Result.ExtendsFalse()
108+
) as never
109+
}
110+
// ----------------------------------------------------------------------------
88111
// ExtendsRightEnum
89112
// ----------------------------------------------------------------------------
90113
type TExtendsRightEnum<Inferred extends TProperties, Left extends TSchema, Right extends TEnumValue[],
@@ -159,6 +182,7 @@ function ExtendsRightUnion<Inferred extends TProperties, Left extends TSchema, R
159182
// ----------------------------------------------------------------------------
160183
export type TExtendsRight<Inferred extends TProperties, Left extends TSchema, Right extends TSchema> = (
161184
Right extends TAny ? TExtendsRightAny<Inferred, Left> :
185+
Right extends Base ? TExtendsRightBase<Inferred, Left, Right> :
162186
Right extends TEnum<infer Values extends TEnumValue[]> ? TExtendsRightEnum<Inferred, Left, Values> :
163187
Right extends TInfer<infer Name extends string, infer Type extends TSchema> ? TExtendsRightInfer<Inferred, Name, Left, Type> :
164188
Right extends TTemplateLiteral<infer Pattern extends string> ? TExtendsRightTemplateLiteral<Inferred, Left, Pattern> :
@@ -172,6 +196,7 @@ export function ExtendsRight<Inferred extends TProperties, Left extends TSchema,
172196
TExtendsRight<Inferred, Left, Right> {
173197
return (
174198
IsAny(right) ? ExtendsRightAny(inferred, left) :
199+
IsBase(right) ? ExtendsRightBase(inferred, left, right) :
175200
IsEnum(right) ? ExtendsRightEnum(inferred, left, right.enum) :
176201
IsInfer(right) ? ExtendsRightInfer(inferred, right.name, left, right.extends) :
177202
IsIntersect(right) ? ExtendsRightIntersect(inferred, left, right.allOf) :

src/type/types/base.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ export class Base<Value extends unknown = unknown> implements TSchema, XGuard<Va
9292
public Clone(): Base {
9393
throw Error('Clone not implemented')
9494
}
95+
/** Checks if this type equals another instance. Override for custom equality. */
96+
public Equals(other: unknown): other is this {
97+
return (
98+
other !== null &&
99+
typeof other === 'object' &&
100+
'constructor' in other &&
101+
this.constructor === other.constructor
102+
)
103+
}
95104
}
96105
// ------------------------------------------------------------------
97106
// Guard

test/typebox/runtime/type/engine/action/evaluate.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -806,15 +806,17 @@ Test('Should Evaluate 64', () => {
806806
Type.Object({ value: Type.Optional(new Foo()) })
807807
]))
808808
Assert.IsTrue(Type.IsOptional(T.properties.value))
809-
Assert.IsTrue(Type.IsNever(T.properties.value))
809+
Assert.IsTrue(Type.IsBase(T.properties.value))
810+
Assert.IsTrue(T.properties.value instanceof Foo)
810811
})
811812
Test('Should Evaluate 65', () => {
812813
const T = Type.Evaluate(Type.Intersect([
813814
Type.Object({ value: new Foo() }),
814815
Type.Object({ value: new Foo() })
815816
]))
816817
Assert.IsFalse(Type.IsOptional(T.properties.value))
817-
Assert.IsTrue(Type.IsNever(T.properties.value))
818+
Assert.IsTrue(Type.IsBase(T.properties.value))
819+
Assert.IsTrue(T.properties.value instanceof Foo)
818820
})
819821
Test('Should Evaluate 66', () => {
820822
const T = Type.Evaluate(Type.Intersect([

test/typebox/runtime/type/extends/base.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,13 @@ export class TBase extends Type.Base<unknown> {
1414

1515
const Base = new TBase()
1616
// ------------------------------------------------------------------
17-
// Identity: Note, it's not obvious to how to handle Base checks
18-
// without relying on nominal typing, while technically possible
19-
// to check for the constructor, bundlers and downlevel targets
20-
// make this a unreliable check. For now, Base is considered a
21-
// distinct type of itself.
17+
// Identity: Base types extend each other if they are instances
18+
// of the same class (constructor check). This models TypeScript's
19+
// structural type checking at runtime.
2220
// ------------------------------------------------------------------
2321
Test('Should Extends 0', () => {
24-
const R: ExtendsResult.TExtendsFalse = Extends({}, Base, Base)
25-
Assert.IsTrue(ExtendsResult.IsExtendsFalse(R))
22+
const R: ExtendsResult.TExtendsTrue<{}> = Extends({}, Base, Base)
23+
Assert.IsTrue(ExtendsResult.IsExtendsTrue(R))
2624
})
2725
// ------------------------------------------------------------------
2826
// Invariant
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { Assert } from 'test'
2+
import * as Type from 'typebox'
3+
4+
const Test = Assert.Context('Type.Base.Evaluate')
5+
6+
// ------------------------------------------------------------------
7+
// Base Type Intersection with Evaluate
8+
// ------------------------------------------------------------------
9+
class TId extends Type.Base<string> {
10+
public override Check(value: unknown): value is string {
11+
return typeof value === 'string'
12+
}
13+
public override Clone(): TId {
14+
return new TId()
15+
}
16+
}
17+
18+
Test('Should evaluate intersect of same Base class instances as equal 1', () => {
19+
const schemaNonEvaluated = Type.Intersect([
20+
new TId(),
21+
new TId(),
22+
])
23+
const schemaEvaluated = Type.Evaluate(schemaNonEvaluated)
24+
25+
// Should not be never
26+
Assert.IsFalse(Type.IsNever(schemaEvaluated))
27+
// Should be TId (Base type)
28+
Assert.IsTrue(Type.IsBase(schemaEvaluated))
29+
})
30+
31+
Test('Should evaluate intersect of same Base class instances as equal 2', () => {
32+
const T1 = new TId()
33+
const T2 = new TId()
34+
const schemaNonEvaluated = Type.Intersect([T1, T2])
35+
const schemaEvaluated = Type.Evaluate(schemaNonEvaluated)
36+
37+
// Should not be never
38+
Assert.IsFalse(Type.IsNever(schemaEvaluated))
39+
// Should be TId (Base type)
40+
Assert.IsTrue(Type.IsBase(schemaEvaluated))
41+
// Should be instance of TId
42+
Assert.IsTrue(schemaEvaluated instanceof TId)
43+
})
44+
45+
Test('Should evaluate intersect of different Base class instances as never', () => {
46+
class TId2 extends Type.Base<number> {
47+
public override Check(value: unknown): value is number {
48+
return typeof value === 'number'
49+
}
50+
public override Clone(): TId2 {
51+
return new TId2()
52+
}
53+
}
54+
55+
const schemaNonEvaluated = Type.Intersect([
56+
new TId(),
57+
new TId2(),
58+
])
59+
const schemaEvaluated = Type.Evaluate(schemaNonEvaluated)
60+
61+
// Should be never
62+
Assert.IsTrue(Type.IsNever(schemaEvaluated))
63+
})
64+
65+
Test('Should evaluate single Base type in intersect', () => {
66+
const schemaNonEvaluated = Type.Intersect([
67+
new TId(),
68+
])
69+
const schemaEvaluated = Type.Evaluate(schemaNonEvaluated)
70+
71+
// Should not be never
72+
Assert.IsFalse(Type.IsNever(schemaEvaluated))
73+
// Should be TId (Base type)
74+
Assert.IsTrue(Type.IsBase(schemaEvaluated))
75+
// Should be instance of TId
76+
Assert.IsTrue(schemaEvaluated instanceof TId)
77+
})
78+
79+
Test('Should evaluate intersect of Base type with Object', () => {
80+
const schemaNonEvaluated = Type.Intersect([
81+
new TId(),
82+
Type.Object({ foo: Type.String() }),
83+
])
84+
const schemaEvaluated = Type.Evaluate(schemaNonEvaluated)
85+
86+
// Should be an object with foo property
87+
Assert.IsTrue(Type.IsObject(schemaEvaluated))
88+
Assert.IsTrue('foo' in schemaEvaluated.properties)
89+
Assert.IsTrue(Type.IsString(schemaEvaluated.properties.foo))
90+
})
91+
92+
Test('Should evaluate triple intersect of same Base class instances', () => {
93+
const schemaNonEvaluated = Type.Intersect([
94+
new TId(),
95+
new TId(),
96+
new TId(),
97+
])
98+
const schemaEvaluated = Type.Evaluate(schemaNonEvaluated)
99+
100+
// Should not be never
101+
Assert.IsFalse(Type.IsNever(schemaEvaluated))
102+
// Should be TId (Base type)
103+
Assert.IsTrue(Type.IsBase(schemaEvaluated))
104+
// Should be instance of TId
105+
Assert.IsTrue(schemaEvaluated instanceof TId)
106+
})
107+
108+
// Type-level check (from the original issue)
109+
Test('Should preserve type-level types correctly', () => {
110+
const schemaNonEvaluated = Type.Intersect([
111+
new TId(),
112+
new TId(),
113+
])
114+
type TNonEvaluated = Type.StaticDecode<typeof schemaNonEvaluated>
115+
116+
const schemaEvaluated = Type.Evaluate(schemaNonEvaluated)
117+
type TEvaluated = Type.StaticDecode<typeof schemaEvaluated>
118+
119+
// Runtime checks
120+
Assert.IsFalse(Type.IsNever(schemaEvaluated))
121+
Assert.IsTrue(Type.IsBase(schemaEvaluated))
122+
123+
// Type-level assertions (compile-time checks)
124+
const _nonEval: TNonEvaluated = 'test' // should be string
125+
const _eval: TEvaluated = 'test' // should be string, not never
126+
127+
Assert.IsTrue(true) // If compiles, test passes
128+
})

0 commit comments

Comments
 (0)