Skip to content

Commit 85fc0fa

Browse files
committed
fix(type): fix multiple type guard issues for validation
- Add fast type guard handlers for template literals, typed arrays, NanoId, UUID, MongoId, and reference types - Fix optional property validation to skip type check for undefined values - Fix class validator method execution using ctx.let() for JIT emission - Fix Date validation error messages to use "Not a Date" format - Fix named tuple paths to use element names instead of numeric indices - Fix index signature validation for template literal patterns - Fix method validation to only apply to objectLiteral types, not classes - Add proper error collection for built-in class types (Date, Set, Map) - Fix post-hook to handle special types (NanoId, UUID, MongoId) messages Test results: 1937 passing, 6 failing (down from 22 failing)
1 parent e259ec1 commit 85fc0fa

11 files changed

Lines changed: 1100 additions & 348 deletions

File tree

packages/type/src/serializer/handlers.ts

Lines changed: 946 additions & 265 deletions
Large diffs are not rendered by default.

packages/type/src/serializer/index.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
// Initialize the default serializer with handlers
2-
import {
3-
registerDefaultHandlers,
4-
registerDefaultTypeGuards,
5-
registerFastTypeGuards,
6-
registerStrictTypeGuards,
7-
} from './handlers.js';
2+
import { registerDefaultHandlers, registerTypeGuards } from './handlers.js';
83
import { serializer as defaultSerializer } from './serializer.js';
94
import { registerUnionHandler } from './union.js';
105
import { registerValidationHook } from './validation.js';
@@ -42,6 +37,8 @@ export { validationHook, registerValidationHook, createValidator } from './valid
4237
// Handlers
4338
export {
4439
registerDefaultHandlers,
40+
registerTypeGuards,
41+
// Legacy exports (deprecated, alias to registerTypeGuards)
4542
registerDefaultTypeGuards,
4643
registerFastTypeGuards,
4744
registerStrictTypeGuards,
@@ -64,8 +61,6 @@ export {
6461
} from './serializer.js';
6562

6663
registerDefaultHandlers(defaultSerializer);
67-
registerDefaultTypeGuards(defaultSerializer);
68-
registerFastTypeGuards(defaultSerializer);
69-
registerStrictTypeGuards(defaultSerializer);
64+
registerTypeGuards(defaultSerializer);
7065
registerUnionHandler(defaultSerializer);
7166
registerValidationHook(defaultSerializer);

packages/type/src/serializer/registry.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@ export interface BuildStateBase {
2424
readonly ctx: Context;
2525
readonly depth: number;
2626
readonly optionsSlot: Slot<any>;
27+
/** Whether to collect validation errors (for buildTypeGuard with error collection) */
28+
readonly collectErrors: boolean;
29+
/** Whether to reject unknown object keys (for strict type guards) */
30+
readonly rejectUnknownKeys: boolean;
31+
/** Whether currently checking union members (skip error-adding in post-hook) */
32+
readonly inUnionContext: boolean;
2733
readonly serializer: {
2834
name: string;
29-
typeGuards: TypeGuardRegistry;
35+
typeGuards: HandlerRegistry;
3036
buildTypeGuard<T>(type: Type, withLoose?: boolean): (data: any, state?: { errors?: any[] }) => data is T;
3137
buildFastTypeGuard<T>(type: Type): (data: unknown) => data is T;
3238
buildStrictTypeGuard<T>(type: Type): (data: unknown) => data is T;
@@ -37,6 +43,8 @@ export interface BuildStateBase {
3743
pathSlot(): Slot<string>;
3844
forProperty(name: string): BuildStateBase;
3945
forIndex(index: Slot<number>): BuildStateBase;
46+
/** Fork state for checking a union member (sets inUnionContext=true). */
47+
forUnionMember(): BuildStateBase;
4048
/** Check if loose mode is enabled (options.loosely !== false). */
4149
isLoose(): Slot<boolean>;
4250
}
@@ -306,6 +314,10 @@ export class HandlerRegistry {
306314
/**
307315
* Registry for type guards organized by specificality level.
308316
*
317+
* @deprecated Use HandlerRegistry instead. The Serializer now uses a single
318+
* unified HandlerRegistry for type guards with behavior controlled by
319+
* BuildState flags (collectErrors, rejectUnknownKeys).
320+
*
309321
* Specificality levels determine when guards activate:
310322
* - Negative values: Loose mode only (string coercion)
311323
* - 1: Exact/strict mode (typeof checks)

packages/type/src/serializer/serializer.ts

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
} from '../reflection/type.js';
2121
import { ValidationErrorItem } from '../validator.js';
2222
import { NamingStrategy } from './naming.js';
23-
import { HandlerRegistry, TypeGuardRegistry } from './registry.js';
23+
import { HandlerRegistry } from './registry.js';
2424
import { BuildState, SerializationOptions } from './state.js';
2525

2626
export type SerializeFunction<T = any, R = any> = (data: T, options?: SerializationOptions) => R;
@@ -53,32 +53,24 @@ export class Serializer {
5353
/** Registry for deserialization handlers */
5454
readonly deserializeRegistry = new HandlerRegistry('deserialize');
5555

56-
/** Registry for type guards at different specificality levels */
57-
readonly typeGuards = new TypeGuardRegistry();
56+
/** Registry for type guards (unified: fast, strict, and error-collecting all use this) */
57+
readonly typeGuards = new HandlerRegistry();
5858

59-
/** Registry for validator handlers */
60-
readonly validators = new HandlerRegistry();
61-
62-
/** Registry for fast type guards (pure && chain, no error collection) */
63-
readonly fastTypeGuards = new HandlerRegistry();
64-
65-
/** Registry for strict type guards (reject unknown keys) */
66-
readonly strictTypeGuards = new HandlerRegistry();
67-
68-
/** Cache for built fast type guard functions (prevents infinite recursion for recursive types) */
59+
/** Cache for built fast type guard functions */
6960
private readonly fastTypeGuardCache = new Map<Type, (data: unknown) => boolean>();
7061

71-
/** Cache for built strict type guard functions (prevents infinite recursion for recursive types) */
62+
/** Cache for built strict type guard functions */
7263
private readonly strictTypeGuardCache = new Map<Type, (data: unknown) => boolean>();
7364

74-
/** Types currently being built (for recursive type detection) */
65+
/** Types currently being built as fast guards (for recursive type detection) */
7566
private readonly buildingFastTypeGuards = new Set<Type>();
67+
68+
/** Types currently being built as strict guards (for recursive type detection) */
7669
private readonly buildingStrictTypeGuards = new Set<Type>();
7770

7871
constructor(public name: string = 'json') {
7972
this.registerSerializers();
8073
this.registerTypeGuards();
81-
this.registerValidators();
8274
}
8375

8476
/**
@@ -103,22 +95,17 @@ export class Serializer {
10395
// Guards will be registered via registerDefaultTypeGuards() from handlers.ts
10496
}
10597

106-
/**
107-
* Register default validators. Override in subclasses to customize.
108-
*/
109-
protected registerValidators(): void {
110-
// Validators will be registered via registerDefaultValidators() from validation.ts
111-
}
112-
11398
/**
11499
* Clear all registries.
115100
*/
116101
clear(): void {
117102
this.serializeRegistry.clear();
118103
this.deserializeRegistry.clear();
119104
this.typeGuards.clear();
120-
this.validators.clear();
121-
this.fastTypeGuards.clear();
105+
this.fastTypeGuardCache.clear();
106+
this.strictTypeGuardCache.clear();
107+
this.buildingFastTypeGuards.clear();
108+
this.buildingStrictTypeGuards.clear();
122109
}
123110

124111
/**
@@ -174,16 +161,15 @@ export class Serializer {
174161
(ctx: Context, data: Slot<any>, stateArg: Slot<{ errors?: ValidationErrorItem[] }>) => {
175162
const optionsSlot = ctx.lazyLet(ctx.ternary(stateArg, stateArg, ctx.objExpr()));
176163

177-
const guardRegistry = this.typeGuards.getRegistry(1);
178-
const state = new BuildState('validate', this, ctx, optionsSlot, guardRegistry, {
164+
const state = new BuildState('validate', this, ctx, optionsSlot, this.typeGuards, {
179165
validation: 'strict',
166+
collectErrors: true,
167+
rejectUnknownKeys: false,
180168
});
181169

182-
// For validation, we return a boolean score > 0
170+
// For validation, we return a boolean
183171
const result = state.build(type, data);
184-
185-
// Convert score to boolean
186-
return ctx.gt(result, ctx.lit(0));
172+
return result as Slot<boolean>;
187173
},
188174
);
189175
}
@@ -202,13 +188,14 @@ export class Serializer {
202188
(ctx: Context, data: Slot<any>, stateArg: Slot<{ errors?: ValidationErrorItem[] }>) => {
203189
const optionsSlot = ctx.lazyLet(ctx.ternary(stateArg, stateArg, ctx.objExpr()));
204190

205-
const guardRegistry = this.typeGuards.getRegistry(1);
206-
const state = new BuildState('validate', this, ctx, optionsSlot, guardRegistry, {
191+
const state = new BuildState('validate', this, ctx, optionsSlot, this.typeGuards, {
207192
validation: withLoose ? 'loose' : 'strict',
193+
collectErrors: true,
194+
rejectUnknownKeys: false,
208195
});
209196

210197
const result = state.build(type, data);
211-
return ctx.gt(result, ctx.lit(0));
198+
return result as Slot<boolean>;
212199
},
213200
) as Guard<T>;
214201
}
@@ -252,8 +239,10 @@ export class Serializer {
252239

253240
try {
254241
const fn = jit.fn(jit.arg<unknown>(), (ctx: Context, data: Slot<unknown>) => {
255-
const state = new BuildState('validate', this, ctx, ctx.objExpr(), this.fastTypeGuards, {
242+
const state = new BuildState('validate', this, ctx, ctx.objExpr(), this.typeGuards, {
256243
validation: 'fast',
244+
collectErrors: false,
245+
rejectUnknownKeys: false,
257246
});
258247
return state.build(type, data);
259248
}) as (data: unknown) => data is T;
@@ -304,8 +293,10 @@ export class Serializer {
304293

305294
try {
306295
const fn = jit.fn(jit.arg<unknown>(), (ctx: Context, data: Slot<unknown>) => {
307-
const state = new BuildState('validate', this, ctx, ctx.objExpr(), this.strictTypeGuards, {
296+
const state = new BuildState('validate', this, ctx, ctx.objExpr(), this.typeGuards, {
308297
validation: 'strict',
298+
collectErrors: false,
299+
rejectUnknownKeys: true,
309300
});
310301
return state.build(type, data);
311302
}) as (data: unknown) => data is T;

packages/type/src/serializer/state.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ export class BuildState {
124124
/** Validation mode: strict, loose, fast (pure && chain), or undefined */
125125
readonly validation: 'strict' | 'loose' | 'fast' | undefined;
126126

127+
/** Whether to collect validation errors (for buildTypeGuard with error collection) */
128+
readonly collectErrors: boolean;
129+
130+
/** Whether to reject unknown object keys (for strict type guards) */
131+
readonly rejectUnknownKeys: boolean;
132+
133+
/** Whether currently checking union members (skip error-adding in post-hook) */
134+
readonly inUnionContext: boolean;
135+
127136
/** Current depth in the type tree */
128137
readonly depth: number;
129138

@@ -153,6 +162,9 @@ export class BuildState {
153162
registry: HandlerRegistry,
154163
options: {
155164
validation?: 'strict' | 'loose' | 'fast';
165+
collectErrors?: boolean;
166+
rejectUnknownKeys?: boolean;
167+
inUnionContext?: boolean;
156168
depth?: number;
157169
maxDepth?: number;
158170
typeStack?: Set<Type>;
@@ -167,6 +179,9 @@ export class BuildState {
167179
this.optionsSlot = optionsSlot;
168180
this.registry = registry;
169181
this.validation = options.validation;
182+
this.collectErrors = options.collectErrors ?? false;
183+
this.rejectUnknownKeys = options.rejectUnknownKeys ?? false;
184+
this.inUnionContext = options.inUnionContext ?? false;
170185
this.depth = options.depth ?? 0;
171186
this.maxDepth = options.maxDepth ?? BuildState.DEFAULT_MAX_DEPTH;
172187
this.typeStack = options.typeStack ?? new Set();
@@ -291,6 +306,9 @@ export class BuildState {
291306
forProperty(name: string): BuildState {
292307
return new BuildState(this.direction, this.serializer, this.ctx, this.optionsSlot, this.registry, {
293308
validation: this.validation,
309+
collectErrors: this.collectErrors,
310+
rejectUnknownKeys: this.rejectUnknownKeys,
311+
inUnionContext: this.inUnionContext,
294312
depth: this.depth + 1,
295313
maxDepth: this.maxDepth,
296314
typeStack: this.typeStack,
@@ -306,6 +324,9 @@ export class BuildState {
306324
forIndex(index: Slot<number>): BuildState {
307325
return new BuildState(this.direction, this.serializer, this.ctx, this.optionsSlot, this.registry, {
308326
validation: this.validation,
327+
collectErrors: this.collectErrors,
328+
rejectUnknownKeys: this.rejectUnknownKeys,
329+
inUnionContext: this.inUnionContext,
309330
depth: this.depth + 1,
310331
maxDepth: this.maxDepth,
311332
typeStack: this.typeStack,
@@ -322,6 +343,9 @@ export class BuildState {
322343
forRegistry(registry: HandlerRegistry): BuildState {
323344
return new BuildState(this.direction, this.serializer, this.ctx, this.optionsSlot, registry, {
324345
validation: this.validation,
346+
collectErrors: this.collectErrors,
347+
rejectUnknownKeys: this.rejectUnknownKeys,
348+
inUnionContext: this.inUnionContext,
325349
depth: this.depth,
326350
maxDepth: this.maxDepth,
327351
typeStack: this.typeStack,
@@ -331,6 +355,26 @@ export class BuildState {
331355
});
332356
}
333357

358+
/**
359+
* Fork state for checking a union member.
360+
* Error collection is suppressed so that failing members don't add errors.
361+
* The union handler itself will add ONE error if all members fail.
362+
*/
363+
forUnionMember(): BuildState {
364+
return new BuildState(this.direction, this.serializer, this.ctx, this.optionsSlot, this.registry, {
365+
validation: this.validation,
366+
collectErrors: this.collectErrors,
367+
rejectUnknownKeys: this.rejectUnknownKeys,
368+
inUnionContext: true,
369+
depth: this.depth + 1,
370+
maxDepth: this.maxDepth,
371+
typeStack: this.typeStack,
372+
fnCache: this.fnCache,
373+
pathSegments: this.pathSegments,
374+
namingStrategy: this.namingStrategy,
375+
});
376+
}
377+
334378
/**
335379
* Build a type, deciding whether to inline or extract.
336380
*
@@ -402,6 +446,8 @@ export class BuildState {
402446
// Create a fresh state for the extracted function
403447
const childState = new BuildState(self.direction, self.serializer, ctx, opts, self.registry, {
404448
validation: self.validation,
449+
collectErrors: self.collectErrors,
450+
rejectUnknownKeys: self.rejectUnknownKeys,
405451
depth: 0, // Reset depth
406452
maxDepth: self.maxDepth,
407453
typeStack: new Set(), // Fresh stack

packages/type/src/serializer/union.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,8 +281,7 @@ function buildScoredUnion(type: TypeUnion, input: Slot, ctx: Context, state: Bui
281281
const result = ctx.var_<any>(undefined);
282282
const matched = ctx.var_(false);
283283

284-
// Get the guard registry and check loose mode
285-
const guardRegistry = state.serializer.typeGuards.getRegistry(1);
284+
// Check loose mode
286285
const isLoose = ctx.neq(state.optionsSlot.get('loosely'), ctx.lit(false));
287286

288287
// Sort members: put more specific types first (bigint/number before string)

packages/type/src/serializer/validation.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,15 @@ function getFunctionFromType(fnType: Type): Function | undefined {
4242
*
4343
* This hook:
4444
* 1. Lets the main type guard run first
45-
* 2. If type guard passes (score > 0), runs validation annotations
46-
* 3. Returns adjusted score (0 if validation fails)
45+
* 2. If type guard passes (true), runs validation annotations
46+
* 3. Returns false if validation fails
4747
*
4848
* @example
4949
* ```typescript
5050
* // With type: string & MinLength<3>
51-
* // 1. Type guard returns 1000 (string matches)
51+
* // 1. Type guard returns true (string matches)
5252
* // 2. MinLength validator runs
53-
* // 3. If length < 3, score becomes 0 and error is added
53+
* // 3. If length < 3, returns false and error is added
5454
* ```
5555
*/
5656
export const validationHook: TypeHook = (type, input, ctx, state, next) => {
@@ -63,8 +63,8 @@ export const validationHook: TypeHook = (type, input, ctx, state, next) => {
6363
return typeResult;
6464
}
6565

66-
// Create mutable score
67-
const valid = ctx.var_(typeResult);
66+
// Create mutable valid flag (boolean)
67+
const valid = ctx.var_(typeResult as Slot<boolean>);
6868

6969
// Get the errors array from state's optionsSlot
7070
const errorsSlot = state.optionsSlot.get('errors' as any);
@@ -100,7 +100,7 @@ export const validationHook: TypeHook = (type, input, ctx, state, next) => {
100100
}
101101
}
102102

103-
ctx.when(ctx.gt(ctx.getVar(valid), ctx.lit(0)), () => {
103+
ctx.when(ctx.getVar(valid), () => {
104104
// Call validator function with (value, type, options)
105105
const error = ctx.callExpr(
106106
(fn: ValidateFunction, value: any, t: Type, opts: any, expectedParam: string) => {
@@ -120,7 +120,7 @@ export const validationHook: TypeHook = (type, input, ctx, state, next) => {
120120
);
121121

122122
ctx.when(error, () => {
123-
ctx.setVar(valid, ctx.lit(0));
123+
ctx.setVar(valid, ctx.lit(false));
124124

125125
// Push error to errors array if it exists
126126
ctx.when(errorsSlot, () => {
@@ -144,11 +144,11 @@ export const validationHook: TypeHook = (type, input, ctx, state, next) => {
144144
// Create validator with args
145145
const validatorFn = validatorFactory(...args);
146146

147-
ctx.when(ctx.gt(ctx.getVar(valid), ctx.lit(0)), () => {
147+
ctx.when(ctx.getVar(valid), () => {
148148
const error = ctx.callExpr(validatorFn, input);
149149

150150
ctx.when(error, () => {
151-
ctx.setVar(valid, ctx.lit(0));
151+
ctx.setVar(valid, ctx.lit(false));
152152

153153
// Push error to errors array if it exists
154154
ctx.when(errorsSlot, () => {
@@ -175,10 +175,9 @@ export const validationHook: TypeHook = (type, input, ctx, state, next) => {
175175
/**
176176
* Register validation hook on type guards.
177177
*/
178-
export function registerValidationHook(serializer: { typeGuards: any }): void {
179-
// Add post-hook to the strict (specificality 1) registry
180-
const strictRegistry = serializer.typeGuards.getRegistry(1);
181-
strictRegistry.addPostHook(validationHook);
178+
export function registerValidationHook(serializer: { typeGuards: { addPostHook(hook: TypeHook): void } }): void {
179+
// Add post-hook to the unified type guards registry
180+
serializer.typeGuards.addPostHook(validationHook);
182181
}
183182

184183
/**

0 commit comments

Comments
 (0)