Skip to content

Conversation

@Teyik0
Copy link
Contributor

@Teyik0 Teyik0 commented Dec 7, 2025

This fix #1606 and #1138 by adding coerceFormdata capabilities.

At first I just wanted to fix #1606 when I saw that t.Optional was'nt working with t.ObjectString but when changing t.ObjectString default value from '{}' to options.default it was breaking t.ArrayQuery. I was in a dead end.

Then after looking at the code, I understood that Elysia coercion is amazing and that I just needed that out of the box for nested formdata. Thus, I wanted to fix replaceSchema function and somehow end up rewriting it to make it more readable and functional. The root cause was somewhere in the composeProperties function, but in any case with the rewrite it's not needed anymore.

Finally, since the rewrite was working and fixing 1606, I just needed to add a coerceFormdata function such as stringToStructureCoercions for ArrayQuery. And of course a bunch of new tests (most of the PR is new test).

The only drawback is that coerceFormdata is only working for elysia type system. But all already coercion is only working for elysia type system, thus I think it's ok like this.

I hope you will take some time to review it, and, in any case I learned so much reading the code base and for that big thanks!

Summary by CodeRabbit

  • New Features

    • Enhanced validation for form data submissions with file uploads and complex nested structures.
  • Bug Fixes

    • Improved handling of union-based schemas during body validation, ensuring properties are correctly extracted and processed.
  • Tests

    • Added comprehensive test coverage for FormData handling with file uploads and nested object validation.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 7, 2025

Walkthrough

A comprehensive schema transformation refactoring that extracts complex schema replacement and coercion logic into a dedicated module, enhances import resolution and file-type detection, updates ObjectString default value handling, and redistributes schema utilities to improve code organization and maintainability.

Changes

Cohort / File(s) Summary
Schema Transformation Engine
src/replace-schema.ts
New module introducing ReplaceSchemaTypeOptions interface, replaceSchemaTypeFromManyOptions function, and memoized coercion helpers (stringToStructureCoercions, queryCoercions, coercePrimitiveRoot, coerceFormData) for recursive schema tree traversal and transformation.
Schema Module Refactoring
src/schema.ts
Adds resolveSchema helper, enhances hasType to unwrap Import/Ref and detect File types, refactors coercion application to use replaceSchemaTypeFromManyOptions, removes embedded replacement logic (moved to replace-schema).
Core Export/Import Redistribution
src/index.ts
Updates public API to export replaceSchemaType from replace-schema, adds dynamic coercion logic in add() to conditionally apply form-data or primitive coercion based on resolved schema, redistributes utility imports.
Validation & Body Handling
src/compose.ts
Enhances union-based file schema handling in body validation path, changes coercePrimitiveRoot import source from schema to replace-schema.
Type System Configuration
src/type-system/index.ts
Updates ObjectString default value from literal '{}' to use options?.default for configurability; adds elysiaMeta metadata to ArrayString union.
File Type Detection Tests
test/aot/has-type.test.ts
Adds extensive tests validating hasType detects File type across unions, Import-wrapped schemas, objects with File fields, arrays of File, and t.Files() alias.
FormData Handling Tests
test/type-system/formdata.test.ts
New comprehensive test suite for nested FormData validation with file uploads, multiple object shapes (simple/complex), optional fields, and stringified JSON coercion.
ObjectString Behavior Tests
test/type-system/object-string.test.ts
Updates ObjectString expectations to reflect new default value behavior; adds tests for optional ObjectString fields and configurable defaults.
Schema Replacement Tests
test/units/replace-schema-type.test.ts
Updates test imports and function usage to align with new replaceSchemaType API; adds new helpers (revertObjAndArrStr, coerceFormData); expands transformation scenarios.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • New module creation: src/replace-schema.ts introduces substantial logic with recursive tree traversal, conditional transformations, memoized caching, and multiple configuration patterns that require careful validation of correct schema mutation semantics.
  • API surface changes: Public export of replaceSchemaType redirected with renamed internal function; heterogeneous changes across index.ts, schema.ts requiring verification of dependency chains.
  • Type system modifications: ObjectString/ArrayString behavior changes touch foundational schema generation; potential impact on existing user code.
  • Complex schema resolution: New resolveSchema logic and enhanced hasType with Import/Ref unwrapping require tracing through schema tree traversals.
  • Distributed refactoring: Changes span multiple files (compose.ts, schema.ts, type-system/index.ts, index.ts) with interdependent logic updates; requires cross-file reasoning.

Areas requiring extra attention:

  • Correctness of recursive traversal and node matching logic in replaceSchemaTypeFromOption (src/replace-schema.ts)
  • Verification that coercion application timing and conditions in add() (src/index.ts) correctly distinguish form-data vs primitive coercion
  • Impact of ObjectString default value change on existing schemas and backward compatibility
  • Proper handling of union unwrapping in composeHandler (src/compose.ts) for edge cases with nested anyOf structures

Possibly related PRs

  • Elysia 1.4: Weirs #1398: Directly overlaps in scope—both modify core schema/coercion engine, validation/composition code, and schema utilities (replaceSchemaType, coerce functions), representing continuation of the same refactor effort.
  • 1.4.5 patch #1403: Related through schema import-resolution changes in src/schema.ts; both modify how Import/$ref schemas are resolved and substituted.

Poem

🐰 A schema's tangled vines, now pruned with care,
Coercions sorted out, transformation fair!
ObjectString defaults bloom, import refs unfold,
FormData flows like streams—defaults take hold,
The refactor hops onward, making schemas gold! ✨

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix nested formdata' is specific and directly related to the PR's main objective of adding coerceFormdata support for nested form data handling.
Linked Issues check ✅ Passed The PR comprehensively addresses issue #1606 by fixing ObjectString default values, enabling t.Optional with ObjectString, and implementing coerceFormdata for nested form data coercion.
Out of Scope Changes check ✅ Passed All changes are in-scope: schema transformation logic refactoring, coerceFormdata implementation, ObjectString default handling, and comprehensive tests for nested FormData scenarios.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

/**
* Resolve a schema that might be a model reference (string) to the actual schema
*/
export const resolveSchema = (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed in order to resolve string imported model. Otherwise, t.model for example would not use coerceFormdata when needed.

return models?.[schema]
}

export const hasType = (type: string, schema: TAnySchema): boolean => {
Copy link
Contributor Author

@Teyik0 Teyik0 Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I somehow enhanced all edgecase that was previsouly not handled on this function

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/schema.ts (1)

690-710: additionalProperties fallback with onlyFirst: 'object' likely stops recursion too early.

In the fallback branch:

schema = replaceSchemaTypeFromManyOptions(schema, {
  onlyFirst: "object",
  from: t.Object({}),
  to(schema) {
    if (!schema.properties) return schema;
    if ("additionalProperties" in schema) return schema;

    return t.Object(schema.properties, {
      ...schema,
      additionalProperties: false,
    });
  }
});

the walker:

  • Treats the first object node it sees as onlyFirst, and
  • Returns immediately from walk for that node without descending into its children.

This has two effects:

  1. If that first object already has additionalProperties set, to() returns the schema unchanged, but children are never visited, so nested objects elsewhere won’t get the default applied.
  2. For non‑object roots (e.g., unions of multiple objects), only the first object encountered in the tree can ever be considered; other object branches are skipped.

If the intent is to ensure that all object schemas lacking additionalProperties (outside the main schema.type === 'object' branch) default to false, onlyFirst should not be used here. Consider:

schema = replaceSchemaTypeFromManyOptions(schema, {
  from: t.Object({}),
  to(schema) {
    if (!schema.properties) return schema;
    if ("additionalProperties" in schema) return schema;

    return t.Object(schema.properties, {
      ...schema,
      additionalProperties: false,
    });
  }
});

This will visit every object node and only add additionalProperties: false where it’s currently missing.

🧹 Nitpick comments (5)
test/type-system/formdata.test.ts (1)

682-909: Skipped Zod tests are documented, but consider tracking as future work.

The skipped Zod tests indicate that nested FormData coercion currently only works with the Elysia type system. This is consistent with the existing coercion behavior mentioned in the PR description.

Consider creating a follow-up issue to track Zod/standard schema support for nested FormData coercion if this is planned for future implementation.

src/index.ts (1)

45-52: Body coercion selection for File/FormData looks correct, but the lambda is duplicated.

The new additionalCoerce logic in createValidator correctly:

  • Resolves model strings via resolveSchema.
  • Guards on Kind in resolved to avoid touching standard schemas.
  • Uses hasType('File' | 'Files', resolved) to choose coerceFormData() vs coercePrimitiveRoot().

This should safely limit nested formdata coercion to Elysia/TypeBox schemas containing File/Files, matching the PR intent.

The same IIFE is duplicated in both the precompiled and lazy createBody branches, though. Consider extracting a small helper (e.g. getBodyAdditionalCoerce(cloned.body, models, modules)) to centralize this logic and reduce future drift.

Also applies to: 167-167, 586-597, 659-665

src/replace-schema.ts (2)

5-19: Traversal semantics for replaceSchemaTypeFromManyOptions / ReplaceSchemaTypeOptions fit current call sites.

  • The walker correctly:
    • Stops at elysiaMeta wrappers to avoid double-wrapping.
    • Supports excludeRoot, rootOnly, untilObjectFound, and onlyFirst in a way that matches how you use them (eg, stop at first nested object/array for coerceFormData, stop before first object for primitive coercions with untilObjectFound).
    • Recurses through oneOf/anyOf/allOf, not, properties, and items and then applies the transform at the current node.

Two small API nits:

  • original?: TAnySchema is currently unused anywhere.
  • to(schema: TSchema): TSchema | null is treated as always returning TSchema; null is never handled.

If you don’t plan to support “skip by returning null” or use original, consider simplifying the interface to to(schema: TSchema): TSchema and dropping original to keep the contract tight.

Also applies to: 52-152


175-184: revertObjAndArrStr relies on the internal anyOf layout; document this assumption.

The helper correctly de‑wraps ObjectString/ArrayString by returning anyOf[1] when elysiaMeta is "ObjectString"/"ArrayString". This is fine as long as:

  • The wrapper always uses [stringBranch, objectOrArrayBranch] ordering.
  • The inner branch has no elysiaMeta.

Consider explicitly stating this invariant in a comment or adding a very small runtime assert (e.g., check that anyOf[1].type is "object" / "array") to make future refactors safer.

src/schema.ts (1)

147-183: hasType recursion works for File/Files detection, but doesn’t cover someOf/not.

The new implementation:

  • Unwraps Import schemas via $defs and $ref.
  • Traverses anyOf/oneOf/allOf, arrays (including a special Files as “array of File” case), and object properties.

This is enough for the new body coercion check (hasType('File' | 'Files', ...)) to work across nested/unioned schemas.

If you rely on someOf/not compositions elsewhere, consider extending hasType to also traverse someOf and not (similar to hasAdditionalProperties / hasElysiaMeta) for completeness.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 07b449a and 1273bbd.

📒 Files selected for processing (9)
  • src/compose.ts (2 hunks)
  • src/index.ts (5 hunks)
  • src/replace-schema.ts (1 hunks)
  • src/schema.ts (7 hunks)
  • src/type-system/index.ts (2 hunks)
  • test/aot/has-type.test.ts (1 hunks)
  • test/type-system/formdata.test.ts (1 hunks)
  • test/type-system/object-string.test.ts (2 hunks)
  • test/units/replace-schema-type.test.ts (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
test/aot/has-type.test.ts (1)
src/schema.ts (1)
  • hasType (147-183)
test/type-system/formdata.test.ts (1)
src/universal/request.ts (1)
  • body (81-144)
test/units/replace-schema-type.test.ts (1)
src/replace-schema.ts (2)
  • revertObjAndArrStr (175-184)
  • coerceFormData (248-266)
src/index.ts (2)
src/schema.ts (2)
  • resolveSchema (129-145)
  • hasType (147-183)
src/replace-schema.ts (2)
  • coerceFormData (248-266)
  • coercePrimitiveRoot (228-244)
src/schema.ts (4)
src/index.ts (5)
  • models (453-473)
  • modules (8131-8133)
  • TSchema (8259-8259)
  • replaceSchemaTypeFromManyOptions (8161-8161)
  • t (8138-8138)
src/types.ts (1)
  • StandardSchemaV1Like (58-70)
src/replace-schema.ts (1)
  • replaceSchemaTypeFromManyOptions (37-50)
src/utils.ts (1)
  • randomId (957-976)
🔇 Additional comments (21)
src/type-system/index.ts (2)

306-306: LGTM! Default value now configurable via options.

The change from a hard-coded '{}' to options?.default correctly implements configurable defaults for ObjectString, aligning with the PR objectives. When no default is provided, Value.Create will now return undefined instead of '{}', which fixes the reported issue #1606.


376-379: LGTM! Metadata addition for consistent schema identification.

Adding { elysiaMeta: 'ArrayString' } to the Union aligns with the ObjectString pattern and enables consistent schema detection in the transformation pipeline. This supports the enhanced union-based file schema handling introduced in the compose layer.

test/aot/has-type.test.ts (1)

71-153: LGTM! Comprehensive test coverage for File type detection.

The new test cases thoroughly validate the enhanced hasType traversal logic across multiple scenarios:

  • Direct unions containing File
  • Import-wrapped schemas at various nesting levels
  • Array detection (both t.Array(t.File()) and t.Files())
  • Negative test cases for schemas without File

The tests align well with the enhanced unwrapping and traversal capabilities added to hasType in src/schema.ts.

test/type-system/object-string.test.ts (3)

8-15: LGTM! Tests validate the corrected default behavior.

The updated test correctly validates that:

  • Value.Create(t.ObjectString({})) now returns undefined instead of '{}'
  • Explicitly providing { default: '{}' } produces the expected default

This directly addresses the issue #1606 objective to restore correct default behavior.


93-110: LGTM! Tests validate optional ObjectString behavior.

The test correctly validates that optional ObjectString fields:

  • Don't produce validation errors when omitted
  • Remain undefined in Value.Create output
  • Accept valid values when provided
  • Reject invalid empty objects

This directly addresses issue #1606's objective to make t.Optional work properly when wrapping t.ObjectString.


112-130: LGTM! Comprehensive validation of default value behavior.

The test thoroughly validates ObjectString with explicit defaults:

  • Value.Create correctly returns the default object
  • Value.Check accepts both object and JSON string formats
  • Incomplete or undefined inputs fail validation as expected

This ensures the configurable default feature works correctly across various scenarios.

src/compose.ts (2)

65-65: LGTM! Import source updated to reflect module refactoring.

The import of coercePrimitiveRoot from ./replace-schema aligns with the PR's architectural change to extract schema transformation utilities into a dedicated module. The function usage remains unchanged.


1476-1487: LGTM! Enhanced handling for union-wrapped file schemas.

The new logic correctly handles schemas wrapped in unions (e.g., ObjectString, ArrayString) by:

  1. Attempting to derive properties from candidate.schema.properties or type.properties
  2. Falling back to searching for the object schema within anyOf branches
  3. Skipping candidates without resolvable properties

This ensures file validation works correctly with the coerced FormData schemas introduced by this PR.

test/type-system/formdata.test.ts (1)

1-641: LGTM! Comprehensive test suite for nested FormData handling.

The test file thoroughly validates the nested FormData feature:

  • ✅ Mandatory and optional nested structures
  • ✅ File uploads with nested Objects/Arrays
  • ✅ JSON string coercion for nested data
  • ✅ Validation errors for malformed data
  • ✅ Model references with automatic coercion
  • ✅ Both AOT and non-AOT modes

The test structure is well-organized with clear describe blocks for each scenario, making it easy to understand the expected behavior.

test/units/replace-schema-type.test.ts (4)

4-8: LGTM! Imports updated to use new public API from replace-schema module.

The imports correctly reference the refactored schema transformation utilities:

  • replaceSchemaTypeFromManyOptions aliased as replaceSchemaType
  • New helpers revertObjAndArrStr and coerceFormData for transformation workflows

This aligns with the PR's architectural changes to extract and expose transformation utilities.


103-103: LGTM! Updated to use enhanced transformation API.

The existing tests now correctly use the enhanced to function signature that receives the schema being replaced as a parameter. This enables:

  • Preserving descriptive properties (line 103)
  • Accessing schema properties for transformation (lines 153, 231)

The updates maintain test intent while leveraging the improved API.

Also applies to: 153-153, 231-231


222-562: LGTM! Comprehensive test coverage for transformation options.

The new test suites thoroughly validate the schema transformation engine:

  • ✅ Basic transformations (Object→ObjectString, Array→ArrayString)
  • ✅ Root exclusion (excludeRoot)
  • ✅ First-match stopping (onlyFirst)
  • ✅ Root-only transformation (rootOnly)
  • ✅ Double-wrapping prevention
  • ✅ Bottom-up traversal order
  • ✅ Multiple transformation passes
  • ✅ Composition types (anyOf, oneOf)

Each test validates expected behavior with clear assertions.


564-943: LGTM! Comprehensive validation of reverse transformation and FormData coercion.

The test suites thoroughly validate:

Reverse Transformation (lines 564-661):

  • ✅ Extracting plain Object/Array from ObjectString/ArrayString
  • ✅ Handling non-transformed schemas correctly

coerceFormData (lines 663-943):

  • ✅ First-level conversion only (key behavior)
  • ✅ Deeper nesting remains unchanged
  • ✅ File and Files handling
  • ✅ Multiple sibling transformations
  • ✅ Complex mixed structures

The tests validate the critical behavior that only first-level nested Objects/Arrays are coerced, which is essential for FormData handling where JSON strings are only at the top level.

src/index.ts (1)

8160-8162: Public alias replaceSchemaType from replace-schema is consistent with the refactor.

Re‑exporting replaceSchemaTypeFromManyOptions as replaceSchemaType from ./replace-schema keeps the public API name stable while delegating to the new implementation module. No issues from this change alone.

src/replace-schema.ts (4)

186-205: stringToStructureCoercions matches prior behavior for objects/arrays.

The options:

  • Convert non‑root objects to ObjectString (excludeRoot: true).
  • Convert all arrays to ArrayString.

This aligns with how string‑to‑structure coercion was previously used for headers/params/query. No functional issues here.


206-224: queryCoercions behavior is consistent with string coercions, specialized for arrays.

The query coercions:

  • Wrap non‑root objects with ObjectString (excludeRoot: true).
  • Wrap arrays with ArrayQuery.

This keeps query handling parallel to earlier implementations while enabling array‑specific semantics. Looks good.


226-244: coercePrimitiveRoot correctly targets only the body’s primitive root schema.

Defining:

  • Number → Numeric and
  • Boolean → BooleanString

with rootOnly: true ensures only the root schema is converted, and children are left untouched. This is appropriate for the “primitive body” case and matches how it’s consumed from index.ts.


246-265: coerceFormData options align with “first nested object/array only” semantics.

Using:

  • excludeRoot: true and onlyFirst: 'object' for objects, and
  • excludeRoot: true and onlyFirst: 'array' for arrays,

ensures that:

  • The route‑level body object is not wrapped.
  • The first nested object/array (e.g., a nested payload in multipart/form-data) is converted to ObjectString / ArrayString, and its children are not recursively re‑wrapped.

That matches the nested formdata coercion goal.

src/schema.ts (3)

33-35: resolveSchema helper behavior is sound and consistent with existing map logic.

resolveSchema:

  • Short‑circuits non‑string schemas.
  • Prefers modules.$defs[name] via Import over models[name].

This matches how mapSchema resolves model names and is appropriate to reuse from index.ts for additionalCoerce selection.

Also applies to: 126-145


1236-1277: Cannot verify the review comment: code context not available in repository.

The mentioned file src/schema.ts at lines 1236-1277, the getCookieValidator function, and the stringToStructureCoercions function from ./replace-schema do not appear to exist in the current repository context. The search did not locate these functions or the referenced module, making it impossible to verify the claims about:

  • Whether stringToStructureCoercions is actually imported from ./replace-schema
  • Whether the cookie coercion behavior is preserved
  • Whether the transformation definitions match Elysia's documented cookie coercion behavior

The original approval assessment cannot be confirmed without access to these code elements.


379-437: Consider defensive guards for Import schema resolution if handling untrusted input.

When coerce is true, replaceSchema correctly composes Number → Numeric and Boolean → BooleanString rules with additionalCoerce. When false, only additionalCoerce applies, matching route body logic expectations.

However, accessing schema.$defs[schema.$ref] in the Import branch without guards could fail if the schema is malformed or constructed outside TypeBox's guarantees. If this function accepts user-provided schemas, add optional chaining:

if (schema[Kind] === 'Import') {
  const refSchema = schema.$defs?.[schema.$ref];
  if (refSchema && !hasRef(refSchema)) {
    let unwrapped = refSchema as TSchema;
    if (coerce || hasAdditionalCoerce) {
      unwrapped = replaceSchema(unwrapped);
      if ('$id' in unwrapped && !unwrapped.$defs) {
        unwrapped.$id = `${unwrapped.$id}_coerced_${randomId()}`;
      }
    }
    schema = unwrapped;
  }
}

to prevent undefined access from malformed imports.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 9, 2025

Open in StackBlitz

npm i https://pkg.pr.new/elysiajs/elysia@1607

commit: 1273bbd

@SaltyAom SaltyAom merged commit ff313a2 into elysiajs:main Dec 9, 2025
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

t.ObjectString default value does not work and t.Optional doest not work with t.ObjectString

2 participants