Skip to content
16 changes: 14 additions & 2 deletions src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import {
import { ELYSIA_TRACE, type TraceHandler } from './trace'

import {
coercePrimitiveRoot,
ElysiaTypeCheck,
getCookieValidator,
getSchemaValidator,
Expand All @@ -63,6 +62,7 @@ import type {
SchemaValidator
} from './types'
import { tee } from './adapter/utils'
import { coercePrimitiveRoot } from './replace-schema'

const allocateIf = (value: string, condition: unknown) =>
condition ? value : ''
Expand Down Expand Up @@ -1473,9 +1473,21 @@ export const composeHandler = ({

if (candidate) {
const isFirst = fileUnions.length === 0
// Handle case where schema is wrapped in a Union (e.g., ObjectString coercion)
let properties = candidate.schema?.properties ?? type.properties

// If no properties but schema is a Union, try to find the Object in anyOf
if (!properties && candidate.schema?.anyOf) {
const objectSchema = candidate.schema.anyOf.find((s: any) => s.type === 'object')
if (objectSchema) {
properties = objectSchema.properties
}
}

if (!properties) continue

const iterator = Object.entries(
type.properties
properties
) as [string, TSchema][]

let validator = isFirst ? '\n' : ' else '
Expand Down
26 changes: 20 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,12 @@ import {
} from './utils'

import {
coercePrimitiveRoot,
stringToStructureCoercions,
getSchemaValidator,
getResponseSchemaValidator,
getCookieValidator,
ElysiaTypeCheck,
queryCoercions
hasType,
resolveSchema,
} from './schema'
import {
composeHandler,
Expand Down Expand Up @@ -165,6 +164,7 @@ import type {
InlineHandlerNonMacro,
Router
} from './types'
import { coercePrimitiveRoot, coerceFormData, queryCoercions, stringToStructureCoercions } from './replace-schema'

export type AnyElysia = Elysia<any, any, any, any, any, any, any>

Expand Down Expand Up @@ -588,7 +588,13 @@ export default class Elysia<
dynamic,
models,
normalize,
additionalCoerce: coercePrimitiveRoot(),
additionalCoerce: (() => {
const resolved = resolveSchema(cloned.body, models, modules)
// Only check for Files if resolved schema is a TypeBox schema (has Kind symbol)
return (resolved && Kind in resolved && (hasType('File', resolved) || hasType('Files', resolved)))
? coerceFormData()
: coercePrimitiveRoot()
})(),
validators: standaloneValidators.map((x) => x.body),
sanitize
}),
Expand Down Expand Up @@ -650,7 +656,13 @@ export default class Elysia<
dynamic,
models,
normalize,
additionalCoerce: coercePrimitiveRoot(),
additionalCoerce: (() => {
const resolved = resolveSchema(cloned.body, models, modules)
// Only check for Files if resolved schema is a TypeBox schema (has Kind symbol)
return (resolved && Kind in resolved && (hasType('File', resolved) || hasType('Files', resolved)))
? coerceFormData()
: coercePrimitiveRoot()
})(),
validators: standaloneValidators.map(
(x) => x.body
),
Expand Down Expand Up @@ -8144,8 +8156,10 @@ export {
export {
getSchemaValidator,
getResponseSchemaValidator,
replaceSchemaType
} from './schema'
export {
replaceSchemaTypeFromManyOptions as replaceSchemaType
} from './replace-schema'

export {
mergeHook,
Expand Down
266 changes: 266 additions & 0 deletions src/replace-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import { Kind, type TAnySchema, type TSchema } from "@sinclair/typebox";
import { t } from "./type-system";
import type { MaybeArray } from "./types";

export interface ReplaceSchemaTypeOptions {
from: TSchema;
to(schema: TSchema): TSchema | null;
excludeRoot?: boolean;
rootOnly?: boolean;
original?: TAnySchema;
/**
* Traverse until object is found except root object
**/
untilObjectFound?: boolean;
/**
* Only replace first object type, can be paired with excludeRoot
**/
onlyFirst?: "object" | "array" | (string & {});
}

/**
* Replace schema types with custom transformation
*
* @param schema - The schema to transform
* @param options - Transformation options (single or array)
* @returns Transformed schema
*
* @example
* // Transform Object to ObjectString
* replaceSchemaType(schema, {
* from: t.Object({}),
* to: (s) => t.ObjectString(s.properties || {}, s),
* excludeRoot: true,
* onlyFirst: 'object'
* })
*/
export const replaceSchemaTypeFromManyOptions = (
schema: TSchema,
options: MaybeArray<ReplaceSchemaTypeOptions>,
): TSchema => {
if (Array.isArray(options)) {
let result = schema;
for (const option of options) {
result = replaceSchemaTypeFromOption(result, option);
}
return result;
}

return replaceSchemaTypeFromOption(schema, options);
};

const replaceSchemaTypeFromOption = (
schema: TSchema,
option: ReplaceSchemaTypeOptions,
): TSchema => {
if (option.rootOnly && option.excludeRoot) {
throw new Error("Can't set both rootOnly and excludeRoot");
}
if (option.rootOnly && option.onlyFirst) {
throw new Error("Can't set both rootOnly and onlyFirst");
}
if (option.rootOnly && option.untilObjectFound) {
throw new Error("Can't set both rootOnly and untilObjectFound");
}

type WalkProps = { s: TSchema; isRoot: boolean; treeLvl: number };
const walk = ({ s, isRoot, treeLvl }: WalkProps): TSchema => {
if (!s) return s;
// console.log("walk iteration", { s, isRoot, treeLvl, transformTo: option.to.toString() })

const skipRoot = isRoot && option.excludeRoot;
const fromKind = option.from[Kind];

// Double-wrapping check
if (s.elysiaMeta) {
const fromElysiaMeta = option.from.elysiaMeta;
if (fromElysiaMeta === s.elysiaMeta && !skipRoot) {
return option.to(s) as TSchema;
}
return s;
}

const shouldTransform = fromKind && s[Kind] === fromKind;
if (!skipRoot && option.onlyFirst && s.type === option.onlyFirst) {
if (shouldTransform) {
return option.to(s) as TSchema;
}
return s;
}

if (isRoot && option.rootOnly) {
if (shouldTransform) {
return option.to(s) as TSchema;
}
return s;
}

if (!isRoot && option.untilObjectFound && s.type === "object") {
return s;
}

const newWalkInput = { isRoot: false, treeLvl: treeLvl + 1 };
const withTransformedChildren = { ...s };

if (s.oneOf) {
withTransformedChildren.oneOf = s.oneOf.map((x: TSchema) =>
walk({ ...newWalkInput, s: x }),
);
}
if (s.anyOf) {
withTransformedChildren.anyOf = s.anyOf.map((x: TSchema) =>
walk({ ...newWalkInput, s: x }),
);
}
if (s.allOf) {
withTransformedChildren.allOf = s.allOf.map((x: TSchema) =>
walk({ ...newWalkInput, s: x }),
);
}
if (s.not) {
withTransformedChildren.not = walk({ ...newWalkInput, s: s.not });
}

if (s.properties) {
withTransformedChildren.properties = {};
for (const [k, v] of Object.entries(s.properties)) {
withTransformedChildren.properties[k] = walk({
...newWalkInput,
s: v as TSchema,
});
}
}

if (s.items) {
const items = s.items;
withTransformedChildren.items = Array.isArray(items)
? items.map((x: TSchema) => walk({ ...newWalkInput, s: x }))
: walk({ ...newWalkInput, s: items as TSchema });
}

// Transform THIS node (with children already transformed)
const shouldTransformThis =
!skipRoot && fromKind && withTransformedChildren[Kind] === fromKind;
if (shouldTransformThis) {
return option.to(withTransformedChildren) as TSchema;
}

return withTransformedChildren;
};

return walk({ s: schema, isRoot: true, treeLvl: 0 });
};

/**
* Helper: Extract plain Object from ObjectString
*
* @example
* ObjectString structure:
* {
* elysiaMeta: "ObjectString",
* anyOf: [
* { type: "string", format: "ObjectString" }, // ← String branch
* { type: "object", properties: {...} } // ← Object branch (we want this)
* ]
* }
* ArrayString structure:
* {
* elysiaMeta: "ArrayString",
* anyOf: [
* { type: "string", format: "ArrayString" }, // ← String branch
* { type: "array", items: {...} } // ← Array branch (we want this)
* ]
* }
*/
export const revertObjAndArrStr = (schema: TSchema): TSchema => {
if (schema.elysiaMeta !== "ObjectString" && schema.elysiaMeta !== "ArrayString")
return schema;

const anyOf = schema.anyOf;
if (!anyOf?.[1]) return schema;

// anyOf[1] is the object branch (already clean, no elysiaMeta)
return anyOf[1];
};

let _stringToStructureCoercions: ReplaceSchemaTypeOptions[];

export const stringToStructureCoercions = () => {
if (!_stringToStructureCoercions) {
_stringToStructureCoercions = [
{
from: t.Object({}),
to: (schema) => t.ObjectString(schema.properties || {}, schema),
excludeRoot: true,
},
{
from: t.Array(t.Any()),
to: (schema) => t.ArrayString(schema.items || t.Any(), schema),
},
] satisfies ReplaceSchemaTypeOptions[];
}

return _stringToStructureCoercions;
};

let _queryCoercions: ReplaceSchemaTypeOptions[];

export const queryCoercions = () => {
if (!_queryCoercions) {
_queryCoercions = [
{
from: t.Object({}),
to: (schema) => t.ObjectString(schema.properties ?? {}, schema),
excludeRoot: true,
},
{
from: t.Array(t.Any()),
to: (schema) => t.ArrayQuery(schema.items ?? t.Any(), schema),
},
] satisfies ReplaceSchemaTypeOptions[];
}

return _queryCoercions;
};

let _coercePrimitiveRoot: ReplaceSchemaTypeOptions[];

export const coercePrimitiveRoot = () => {
if (!_coercePrimitiveRoot)
_coercePrimitiveRoot = [
{
from: t.Number(),
to: (schema) => t.Numeric(schema),
rootOnly: true,
},
{
from: t.Boolean(),
to: (schema) => t.BooleanString(schema),
rootOnly: true,
},
] satisfies ReplaceSchemaTypeOptions[];

return _coercePrimitiveRoot;
};

let _coerceFormData: ReplaceSchemaTypeOptions[];

export const coerceFormData = () => {
if (!_coerceFormData)
_coerceFormData = [
{
from: t.Object({}),
to: (schema) => t.ObjectString(schema.properties ?? {}, schema),
onlyFirst: 'object',
excludeRoot: true
},
{
from: t.Array(t.Any()),
to: (schema) => t.ArrayString(schema.items ?? t.Any(), schema),
onlyFirst: 'array',
excludeRoot: true
},
] satisfies ReplaceSchemaTypeOptions[];

return _coerceFormData;
};
Loading
Loading