Skip to content

Conversation

@KurtGokhan
Copy link

@KurtGokhan KurtGokhan commented Oct 25, 2025

I reported similar issue here and attempted to fix this in core here previously, but for now, it's safer to fix Eden first, as that will make issues in the core more apparent.

Added tests for various cases. Note that I disabled these three cases: with-maybe-empty, with-optional, with-union-undefined. These cause runtime failures even if the types are what you would expect. They may need to be fixed in the core, or the schema that can be assigned to query and headers should be restricted.

For example, it can be made not possible to assign t.Undefined(), but it's possible to assign t.Object({}) or t.Partial(...). This is a bit more nuanced, because Eden may add content-type header even if you don't expect any headers, and that causes schema validation to fail. So it's not a good idea to define headers to be a strict schema or undefined. I am open to suggestions.

I believe this also fixes #217

Summary by CodeRabbit

  • New Features

    • Improved type handling for optional/empty route parameters, producing clearer public API signatures for both subscribe and regular routes.
    • Added new public routes: maybe-empty, unknown-or-obj, partial-or-optional.
  • Tests

    • Expanded test coverage for parameter edge cases (empty objects, unknown/undefined, partial/optional, unions) validating expected query/header shapes.

@coderabbitai
Copy link

coderabbitai bot commented Oct 25, 2025

Walkthrough

Introduce MaybeEmptyObject in src/types.ts and propagate it into src/treaty2/types.ts, replacing prior conditional parameter shaping. Signatures for subscribe and regular routes now use MaybeEmptyObject to preserve optional/empty query and header shapes. Tests (runtime and type-level) added/updated to validate behavior.

Changes

Cohort / File(s) Summary
New type utility
src/types.ts
Added exported MaybeEmptyObject<TObj, TKey extends PropertyKey, TFallback = Record<string, unknown>> plus internal helpers (IsExactlyUnknown, IsUndefined, IsMatchingEmptyObject) to conditionally preserve optional/empty object parameter shapes.
Type refactor — treaty types
src/treaty2/types.ts
Imported MaybeEmptyObject and refactored Treaty.Sign (subscribe and non-subscribe branches) and related param inference to use MaybeEmptyObject<..., 'headers'> & MaybeEmptyObject<..., 'query'>, simplifying conditional logic and altering public route signatures.
Runtime tests added
test/treaty2.test.ts
Added /empty-test endpoint group and a parameterized test matrix covering MaybeEmpty, Unknown, empty Record/Object, Partial, Optional, and various unions; asserts returned { query: {}, headers: {} } for selected cases.
Type-level tests added
test/types/treaty2.ts
Added GET routes /maybe-empty, /unknown-or-obj, /partial-or-optional and expanded type assertions to check inferred shapes (e.g., optional fields preserved, empty-object inference).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Review attention: MaybeEmptyObject multi-branch conditional logic and internal guards.
  • Verify interactions in src/treaty2/types.ts affecting subscribe vs non-subscribe route signatures.
  • Inspect new test cases to ensure all branches (unions, empty-record, optional/partial) are covered and type expectations align.

Poem

🐰 I stitched a little MaybeEmpty seam,
Where optionals wandered lost in dream.
Now headers and queries softly gleam,
Types whisper hints, no more extreme,
Hoppity-hop — type clarity supreme! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The PR title "fix: better handling for empty header and query types" accurately describes the primary change in the changeset. The modifications introduce a new MaybeEmptyObject utility type and refactor Treaty.Sign to improve type inference for cases where headers and query parameters are empty, undefined, optional, or have partial definitions. The title clearly conveys the main objective without being vague or overly generic, and it directly aligns with the code changes across all modified files.
Linked Issues Check ✅ Passed The PR directly addresses the objective from issue #217 to preserve type hints for optional query and header parameters. The new MaybeEmptyObject type conditionally shapes parameter types to maintain specific field information instead of collapsing to generic Record<string, unknown>. Test cases validate that routes with optional parameters like /partial-or-optional now generate correct inferred types such as query { alias?: string } and headers { username?: string }, rather than losing field hints to generic Records. The implementation achieves the stated goal of restoring type-level hints for optional parameters.
✨ 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.

Copy link

@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

🧹 Nitpick comments (4)
src/types.ts (1)

58-63: MaybeEmptyObject: cover any/never and document intent

Edge cases:

  • TObj = any → property becomes required; likely unintended.
  • TObj = never → required key of type never.

Add IsAny/IsNever guards and a brief doc to avoid footguns.

 type IsMatchingEmptyObject<T> = [T] extends [{}]
     ? [{}] extends [T]
         ? true
         : false
     : false

+/**
+ * MaybeEmptyObject
+ * - undefined | unknown | {} (or unions containing {}) => optional key
+ * - unions including null | undefined => optional key
+ * - otherwise => required key
+ */
 export type MaybeEmptyObject<
     TObj,
     TKey extends PropertyKey,
     TFallback = Record<string, unknown>
 > = IsUndefined<TObj> extends true
     ? { [K in TKey]?: TFallback }
+    : IsAny<TObj> extends true
+    ? { [K in TKey]?: TFallback }
     : IsExactlyUnknown<TObj> extends true
     ? { [K in TKey]?: TFallback }
     : IsMatchingEmptyObject<TObj> extends true
     ? { [K in TKey]?: TObj }
+    : IsNever<TObj> extends true
+    ? { [K in TKey]?: TFallback }
     : undefined extends TObj
     ? { [K in TKey]?: TObj }
     : null extends TObj
     ? { [K in TKey]?: TObj }
     : { [K in TKey]: TObj }

Also applies to: 64-79

src/treaty2/types.ts (1)

70-73: WS subscribe options should mirror Param-required semantics

options?: Param is always optional. For consistency with HTTP methods, consider making it required when Param has required keys.

-            ? MaybeEmptyObject<Route['subscribe']['headers'], 'headers'> &
-                  MaybeEmptyObject<Route['subscribe']['query'], 'query'> extends infer Param
-                ? (options?: Param) => EdenWS<Route['subscribe']>
+            ? MaybeEmptyObject<Route['subscribe']['headers'], 'headers'> &
+                  MaybeEmptyObject<Route['subscribe']['query'], 'query'> extends infer Param
+                ? ({} extends Param
+                      ? (options?: Param)
+                      : (options: Param)) => EdenWS<Route['subscribe']>
                 : never
test/treaty2.test.ts (1)

395-409: Use test.skip.each instead of commenting out cases

Keeps skipped cases visible in output and easy to re-enable.

-test.each([
-    'with-empty-obj',
-    'with-partial',
-    'with-unknown',
-    'with-empty-record',
-    'with-union-empty-obj',
-    'with-union-empty-record',
-    // 'with-maybe-empty',
-    // 'with-optional',
-    // 'with-union-undefined',
-] as const)('type test for case: %s', async (caseName) => {
+const supportedCases = [
+    'with-empty-obj',
+    'with-partial',
+    'with-unknown',
+    'with-empty-record',
+    'with-union-empty-obj',
+    'with-union-empty-record',
+] as const
+const skippedCases = [
+    'with-maybe-empty',
+    'with-optional',
+    'with-union-undefined',
+] as const
+
+test.each(supportedCases)('type test for case: %s', async (caseName) => {
     const { data, error } = await client['empty-test'][caseName].get()
     expect(error, JSON.stringify(error, null, 2)).toBeNull()
     expect(data).toEqual({ query: {}, headers: {} })
 })
+
+test.skip.each(skippedCases)('type test (pending core fix): %s', async (caseName) => {
+    const { data, error } = await client['empty-test'][caseName].get()
+    expect(error, JSON.stringify(error, null, 2)).toBeNull()
+    expect(data).toEqual({ query: {}, headers: {} })
+})
test/types/treaty2.ts (1)

1175-1224: Type assertions look correct; add union-with-undefined type-only check

You’ve validated maybe-empty/unknown/partial. Consider adding a type-only assertion for union-with-undefined (even if runtime test is skipped) to lock inference.

 }
 
 // Handle partial and optional query and headers
 {
@@
     expectTypeOf<NonNullable<Headers>>().toEqualTypeOf<{ username?: string }>()
 }
+
+// Handle union with undefined (type-only)
+{
+    const app = new Elysia().get('/u', () => 'ok', {
+        query: t.Union([t.Object({ alias: t.String() }), t.Undefined()]),
+        headers: t.Union([t.Object({ username: t.String() }), t.Undefined()]),
+    })
+    const api = treaty(app)
+    type Route = typeof api['u']['get']
+    type RouteOptions = Parameters<Route>[0]
+    type Query = NonNullable<RouteOptions>['query']
+    type Headers = NonNullable<RouteOptions>['headers']
+    expectTypeOf<RouteOptions>().toBeNullable()
+    expectTypeOf<Query>().toBeNullable()
+    expectTypeOf<Headers>().toBeNullable()
+    expectTypeOf<NonNullable<Query>>().toEqualTypeOf<{ alias: string }>()
+    expectTypeOf<NonNullable<Headers>>().toEqualTypeOf<{ username: string }>()
+}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 72a0472 and ea75908.

📒 Files selected for processing (4)
  • src/treaty2/types.ts (3 hunks)
  • src/types.ts (1 hunks)
  • test/treaty2.test.ts (3 hunks)
  • test/types/treaty2.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/treaty2/types.ts (1)
src/types.ts (4)
  • IsNever (42-42)
  • Not (104-104)
  • MaybeEmptyObject (64-78)
  • Prettify (88-90)
🔇 Additional comments (5)
src/treaty2/types.ts (3)

81-134: Nice: Param optionality now derives from required keys

Using {} extends Param to toggle optionality is clean and fixes past ergonomics. LGTM.


138-156: CreateParams: clarity and correctness

Good handling of optional path params; avoiding Prettify at the callable union preserves call signatures. LGTM.


21-41: Exact-void check improvement: verified, but manual testing recommended

The file state matches the review comment. The distinction between void extends B (contravariance/supertypability check) and [B] extends [void] (tuple distribution/exact check) is semantically meaningful:

  • void extends B returns true when B is a supertype like unknown or any, causing incorrect classification
  • [B] extends [void] correctly distinguishes exact void from unknown or any

The proposed change correctly addresses the issue. However, the sandbox environment lacks a TypeScript compiler to validate the three test cases directly. The fix is sound in principle, but manual verification of the mapped types—particularly the first test case with Generator<never, unknown, any>—is recommended to confirm the behavioral difference.

test/treaty2.test.ts (1)

95-132: Coverage for empty/unknown/partial cases looks good

Route matrix is comprehensive and traces real-world schemas. LGTM.

test/types/treaty2.ts (1)

134-145: New routes align with objectives

Routes for maybe-empty, unknown-or-obj, and partial-or-optional model the target scenarios well. LGTM.

Copy link

@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

🧹 Nitpick comments (1)
src/treaty2/types.ts (1)

70-82: Consider adding inline comments to explain the MaybeEmptyObject pattern.

The type logic is sophisticated and correctly handles multiple edge cases (undefined, empty objects, optional properties, etc.). Adding brief comments explaining how MaybeEmptyObject wraps headers and query parameters, and how this preserves type hints while maintaining proper optionality, would help future maintainers quickly understand the implementation.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ea75908 and b3b249b.

📒 Files selected for processing (1)
  • src/treaty2/types.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/treaty2/types.ts (2)
src/types.ts (1)
  • MaybeEmptyObject (64-78)
src/treaty2/ws.ts (1)
  • EdenWS (5-91)
🔇 Additional comments (3)
src/treaty2/types.ts (3)

5-5: LGTM! Import correctly adds MaybeEmptyObject utility.

The import is necessary for the improved type handling of headers and query parameters introduced below.


70-72: Excellent fix for preserving optional parameter types in subscribe routes.

The MaybeEmptyObject approach correctly addresses issue #217 by preserving optional field hints for headers and query parameters rather than collapsing them to Record<string, unknown> | undefined. The intersection of both wrapped types ensures proper optionality of the options parameter.


75-82: Correct implementation of MaybeEmptyObject for regular routes.

The type extraction and parameter shaping using MaybeEmptyObject is consistent with the subscribe route handling and correctly preserves optional field types. The resulting Param type properly integrates with the {} extends Param check on line 83, ensuring the options parameter's optionality matches the actual requirements.

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.

type hints when all query params are optional are lost

1 participant