Skip to content

Commit 1a2dda7

Browse files
committed
fix(type): remove optional chaining from JIT-generated code
Optional chaining (?.) syntax breaks in certain JavaScript runtimes. Instead of using optGet which generates obj?.key, ensure optionsRef is always an object via nullish coalescing at function entry points, then use regular property access. Also adds BuildOptions support for baking serialization options (groups, loosely) into specialized functions at build time, eliminating runtime option checks in hot paths.
1 parent d85885c commit 1a2dda7

4 files changed

Lines changed: 246 additions & 44 deletions

File tree

packages/type/src/serializer-facade.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
Serializer,
2020
getPartialSerializeFunction,
2121
getSerializeFunction,
22+
serializationOptionsToBuildOptions,
2223
serializer,
2324
} from './serializer.js';
2425
import { assert } from './typeguard.js';
@@ -38,7 +39,15 @@ export function cast<T>(
3839
namingStrategy?: NamingStrategy,
3940
type?: ReceiveType<T>,
4041
): T {
41-
const fn = getSerializeFunction(resolveReceiveType(type), serializerToUse.deserializeRegistry, namingStrategy);
42+
// Get specialized function with baked options for zero-overhead hot path
43+
const buildOptions = serializationOptionsToBuildOptions(options);
44+
const fn = getSerializeFunction(
45+
resolveReceiveType(type),
46+
serializerToUse.deserializeRegistry,
47+
namingStrategy,
48+
'',
49+
buildOptions,
50+
);
4251
const item = fn(data, options) as T;
4352
assert(item, undefined, type);
4453
return item;
@@ -83,7 +92,15 @@ export function deserialize<T>(
8392
namingStrategy?: NamingStrategy,
8493
type?: ReceiveType<T>,
8594
): T {
86-
const fn = getSerializeFunction(resolveReceiveType(type), serializerToUse.deserializeRegistry, namingStrategy);
95+
// Get specialized function with baked options for zero-overhead hot path
96+
const buildOptions = serializationOptionsToBuildOptions(options);
97+
const fn = getSerializeFunction(
98+
resolveReceiveType(type),
99+
serializerToUse.deserializeRegistry,
100+
namingStrategy,
101+
'',
102+
buildOptions,
103+
);
87104
return fn(data, options) as T;
88105
}
89106

@@ -229,7 +246,15 @@ export function serialize<T>(
229246
namingStrategy?: NamingStrategy,
230247
type?: ReceiveType<T>,
231248
): JSONSingle<T> {
232-
const fn = getSerializeFunction(resolveReceiveType(type), serializerToUse.serializeRegistry, namingStrategy);
249+
// Get specialized function with baked options for zero-overhead hot path
250+
const buildOptions = serializationOptionsToBuildOptions(options);
251+
const fn = getSerializeFunction(
252+
resolveReceiveType(type),
253+
serializerToUse.serializeRegistry,
254+
namingStrategy,
255+
'',
256+
buildOptions,
257+
);
233258
return fn(data, options) as JSONSingle<T>;
234259
}
235260

@@ -269,7 +294,15 @@ export function validatedDeserialize<T>(
269294
namingStrategy?: NamingStrategy,
270295
type?: ReceiveType<T>,
271296
) {
272-
const fn = getSerializeFunction(resolveReceiveType(type), serializerToUse.deserializeRegistry, namingStrategy);
297+
// Get specialized function with baked options for zero-overhead hot path
298+
const buildOptions = serializationOptionsToBuildOptions(options);
299+
const fn = getSerializeFunction(
300+
resolveReceiveType(type),
301+
serializerToUse.deserializeRegistry,
302+
namingStrategy,
303+
'',
304+
buildOptions,
305+
);
273306
const item = fn(data, options) as T;
274307
assert(item, undefined, type);
275308
return item;

packages/type/src/serializer/serializer.ts

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { ValidationErrorItem } from '../validator.js';
2323
import { registerDefaultHandlers, registerTypeGuards } from './handlers.js';
2424
import { NamingStrategy } from './naming.js';
2525
import { HandlerRegistry } from './registry.js';
26-
import { BuildState, SerializationOptions } from './state.js';
26+
import { BuildOptions, BuildState, SerializationOptions } from './state.js';
2727
import { registerUnionHandler } from './union.js';
2828
import { registerValidationHook } from './validation.js';
2929

@@ -131,8 +131,8 @@ export class Serializer {
131131
arg<T>(),
132132
arg<SerializationOptions>(),
133133
(b: Builder, data: Ref<T>, options: Ref<SerializationOptions>) => {
134-
const optionsRef = b.let(b.nullish(options, b.emptyObj<SerializationOptions>()));
135-
134+
// Ensure options is always an object (for safe property access in handlers)
135+
const optionsRef = b.let(b.nullish(options, b.emptyObj()));
136136
const state = new BuildState('serialize', this, b, optionsRef, this.serializeRegistry);
137137

138138
return state.build(type, data);
@@ -151,8 +151,8 @@ export class Serializer {
151151
arg<any>(),
152152
arg<SerializationOptions>(),
153153
(b: Builder, data: Ref<any>, options: Ref<SerializationOptions>) => {
154-
const optionsRef = b.let(b.nullish(options, b.emptyObj<SerializationOptions>()));
155-
154+
// Ensure options is always an object (for safe property access in handlers)
155+
const optionsRef = b.let(b.nullish(options, b.emptyObj()));
156156
const state = new BuildState('deserialize', this, b, optionsRef, this.deserializeRegistry);
157157

158158
return state.build(type, data);
@@ -378,36 +378,106 @@ export class Serializer {
378378
}
379379
}
380380

381+
/**
382+
* Compute a cache key suffix from build options.
383+
* Returns empty string for default options (no baked values).
384+
*/
385+
export function computeBuildOptionsKey(buildOptions?: BuildOptions): string {
386+
if (!buildOptions) return '';
387+
388+
const parts: string[] = [];
389+
390+
// Loose mode: L=loose, S=strict
391+
if (buildOptions.looseBaked !== undefined) {
392+
parts.push(buildOptions.looseBaked ? 'L' : 'S');
393+
}
394+
395+
// Groups: G:group1,group2 or GX:excludedGroup1,excludedGroup2
396+
if (buildOptions.groupsBaked !== undefined) {
397+
const sorted = [...buildOptions.groupsBaked].sort();
398+
parts.push(`G:${sorted.join(',')}`);
399+
}
400+
if (buildOptions.groupsExcludeBaked !== undefined) {
401+
const sorted = [...buildOptions.groupsExcludeBaked].sort();
402+
parts.push(`GX:${sorted.join(',')}`);
403+
}
404+
405+
return parts.length > 0 ? '_' + parts.join('_') : '';
406+
}
407+
408+
/**
409+
* Convert SerializationOptions to BuildOptions for baking into specialized functions.
410+
*/
411+
export function serializationOptionsToBuildOptions(options?: SerializationOptions): BuildOptions | undefined {
412+
if (!options) return undefined;
413+
414+
const buildOptions: BuildOptions = {};
415+
let hasBakedOptions = false;
416+
417+
// Bake loose mode if explicitly set
418+
if (options.loosely !== undefined) {
419+
buildOptions.looseBaked = options.loosely;
420+
hasBakedOptions = true;
421+
}
422+
423+
// Bake groups if provided
424+
if (options.groups !== undefined) {
425+
buildOptions.groupsBaked = options.groups;
426+
hasBakedOptions = true;
427+
}
428+
if (options.groupsExclude !== undefined) {
429+
buildOptions.groupsExcludeBaked = options.groupsExclude;
430+
hasBakedOptions = true;
431+
}
432+
433+
return hasBakedOptions ? buildOptions : undefined;
434+
}
435+
381436
/**
382437
* Get a cached serializer function for a type.
438+
*
439+
* @param type - The type to serialize
440+
* @param registry - The handler registry (serialize or deserialize)
441+
* @param namingStrategy - Property naming strategy
442+
* @param path - Path prefix for error messages
443+
* @param buildOptions - Optional build-time options to bake into the function
383444
*/
384445
export function getSerializeFunction(
385446
type: Type,
386447
registry: HandlerRegistry,
387448
namingStrategy: NamingStrategy = new NamingStrategy(),
388449
path: string = '',
450+
buildOptions?: BuildOptions,
389451
): SerializeFunction {
390452
const jitContainer = getTypeJitContainer(type);
391-
const id = `${registry.id}_${namingStrategy.id}_${path}`;
453+
const optionsKey = computeBuildOptionsKey(buildOptions);
454+
const id = `${registry.id}_${namingStrategy.id}_${path}${optionsKey}`;
392455

393456
if (jitContainer[id]) {
394457
return jitContainer[id];
395458
}
396459

397-
jitContainer[id] = createSerializeFunction(type, registry, namingStrategy, path);
460+
jitContainer[id] = createSerializeFunction(type, registry, namingStrategy, path, buildOptions);
398461
toFastProperties(jitContainer);
399462

400463
return jitContainer[id];
401464
}
402465

403466
/**
404467
* Create a serializer function for a type (not cached).
468+
*
469+
* @param type - The type to serialize
470+
* @param registry - The handler registry
471+
* @param namingStrategy - Property naming strategy
472+
* @param path - Path prefix for error messages
473+
* @param buildOptions - Optional build-time options to bake into the function
405474
*/
406475
export function createSerializeFunction(
407476
type: Type,
408477
registry: HandlerRegistry,
409478
namingStrategy: NamingStrategy = new NamingStrategy(),
410479
path: string = '',
480+
buildOptions?: BuildOptions,
411481
): SerializeFunction {
412482
// Get direction from registry
413483
const direction = registry.direction;
@@ -416,16 +486,16 @@ export function createSerializeFunction(
416486
arg<any>(),
417487
arg<SerializationOptions>(),
418488
(b: Builder, data: Ref<any>, options: Ref<SerializationOptions>) => {
419-
const optionsRef = b.let(b.nullish(options, b.emptyObj<SerializationOptions>()));
489+
// Ensure options is always an object (for safe property access in handlers)
490+
const optionsRef = b.let(b.nullish(options, b.emptyObj()));
420491

421-
// We need a serializer reference here - for now use default
422492
const state = new BuildState(
423493
direction,
424494
serializer, // Use default serializer
425495
b,
426496
optionsRef,
427497
registry,
428-
{ namingStrategy },
498+
{ namingStrategy, buildOptions },
429499
);
430500

431501
return state.build(type, data);

0 commit comments

Comments
 (0)