Skip to content

fix(inertia): normalize empty props in PageProps - #2103

Open
nkfr26 wants to merge 5 commits into
honojs:mainfrom
nkfr26:fix/inertia-page-props
Open

fix(inertia): normalize empty props in PageProps#2103
nkfr26 wants to merge 5 commits into
honojs:mainfrom
nkfr26:fix/inertia-page-props

Conversation

@nkfr26

@nkfr26 nkfr26 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What

Normalize the props resolved by PageProps:

  • Renders without props resolve to {}.
  • When multiple handlers render the same page, each handler's prop shape becomes its own member of the resulting union.
  • When the page is rendered both with and without props, the props-less variant shares the other renders' keys as optional never, so absent props must be accessed with ?..

Problem

  • Props-less renders resolved to Record<string, never>: accessing nonexistent props produces no error.
  • When the same page is rendered with and without props by different handlers, the merged props type is Record<string, never> | { errors: Record<string, string> }. Because Record<string, never> has an index signature, props.errors type-checks as always present, but the prop can be undefined at runtime when the props-less handler runs.

Fix

Apply an internal NormalizeProps helper in src/page-props.ts:

  • renders without props → {}
  • mixed renders (same page, with and without props) → the props-less variant is rewritten to { [K in keyof OtherVariants]?: never }, keeping existing property accesses valid while forcing ?. for values that may be absent
  • renders with different prop shapes keep their own union members

The normalized result is flattened with Simplify.
The helper is not exported, so the public d.ts surface is unchanged.

Changes

  • src/page-props.ts — normalization, Simplify flattening, MethodOutput distribution
  • src/page-props.test.ts — type tests via PageProps + AppRegistry
  • .changeset/fast-kings-reply.md — patch changeset

Type-only change.

The author should do the following, if applicable

  • Add tests
  • Run tests
  • pnpm changeset at the top of this repo and push the changeset
  • Follow the contribution guide

@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3ae670e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hono/inertia Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@yusukebe

Copy link
Copy Markdown
Member

@nkfr26 Thanks!

Hey @ashunar0 ! Can you review this?

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.25%. Comparing base (952bb5b) to head (8882013).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2103   +/-   ##
=======================================
  Coverage   92.25%   92.25%           
=======================================
  Files         116      116           
  Lines        4132     4132           
  Branches     1081     1080    -1     
=======================================
  Hits         3812     3812           
  Misses        285      285           
  Partials       35       35           
Flag Coverage Δ
firebase-auth 96.61% <ø> (ø)
inertia 100.00% <ø> (ø)
react-renderer 88.88% <ø> (ø)
session 98.91% <ø> (ø)
typebox-validator 100.00% <ø> (ø)
typedriver-validator 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@nkfr26

nkfr26 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

@yusukebe @ashunar0
Please let me know if anything is unclear! In particular, when the presence or absence of props is mixed, making it Partial feels destructive, or rather, tricky to handle. It does improve convenience, but I don't think it's entirely correct. Especially when there are two or more props, I feel this is undesirable. I'd love to hear your thoughts.

@nkfr26

nkfr26 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

After some thought, I've come to believe that the Union pattern is superior. I've included the implementation and comparison below. If you prefer this one, I'll make the revisions again.

Implementation

type NormalizeProps<T> = T extends Record<string, never> ? {} : T

Comparison

const _app = new Hono()
  .use(inertia())
  .get('/without-props', (c) => c.render('WithoutProps'))
  .get('/optional-props',
    (c) => c.render('OptionalProps'),
    (c) => c.render('OptionalProps', { errors: { email: 'invalid' } })
  )

WithoutProps (single handler, props-less only)

Pattern Resolved type
Before the fix Record<string, never>
Partial / Union {}

Accessing a prop that does not exist:

// Before the fix: no error — the index signature of Record<string, never> resolves it to never
props.foo // compiles silently

// Partial / Union: compile error
props.foo // TS2339: Property 'foo' does not exist on type '{}'

OptionalProps (two handlers, with and without props)

Pattern Resolved type
Before the fix { errors: { email: string } } | Record<string, never>
Partial { errors?: { email: string } }
Union { errors: { email: string } } | {}

Accessing errors:

// Before the fix: type-checks as always present, but undefined at runtime
props.errors.email // compiles, crashes when rendered without props

// Partial: direct access, handle undefined normally
props.errors?.email

// Union: narrow first, then access
'errors' in props && props.errors.email

Note: a prop that does not exist (e.g. props.foo) is already a TS2339 error in all three versions — the { errors } member blocks it even before the fix.

Discriminated union style usage

If you expect to use the props like a discriminated union, the Union pattern is the more correct choice.
With the Partial pattern, the same code cannot guarantee props.error — it remains string | undefined.

const _app = new Hono()
  .use(inertia())
  .get(
    '/union-props',
    (c) => c.render('UnionProps'),
    (c) => c.render('UnionProps', { kind: 'ok' as const, value: 0 }),
    (c) => c.render('UnionProps', { kind: 'ng' as const, error: 'message' })
  )
// When `props.kind` is 'ng', `props.error` is guaranteed to be a string
if ('kind' in props && props.kind === 'ng') { props.error }

By the way

I used the errors prop as an example, but since in Inertia it's generally common to obtain it from Form or useForm, this may not have been a very appropriate example. https://inertiajs.com/docs/v3/the-basics/forms#slot-props

@nkfr26
nkfr26 marked this pull request as draft August 22, 2026 13:58
@ashunar0

Copy link
Copy Markdown
Contributor

@nkfr26 Sorry for the slow review. I pulled the branch locally and checked how the types actually behave in a few cases.

First, thanks for catching these two problems. The second one in particular — props.errors.email type-checking when a page is rendered both with and without props — leads straight to a runtime crash, so it definitely needs fixing. I fully agree with the direction.

Some context first

There is no counterpart to this in inertia-laravel. Laravel has no mechanism to derive prop types from the server; you hand-write interface Props on the frontend. The same bug exists there — it's just not caught by types at all.

So "match the original" isn't available to us here. We have to decide what to prioritize ourselves. My two criteria:

  1. The type must not lie (props.errors.email compiling and then crashing at runtime has to go).
  2. The ergonomics of the code users actually write must not regress.

Your concern about Partial was right

Especially when there are two or more props, I feel this is undesirable.

I measured this. Here's a page rendered by one handler with three props and by another with none:

.get(
  '/three',
  (c) => c.render('Three'),
  (c) => c.render('Three', { a: 0, b: 'x', c: true })
)

The Partial version resolves to { a?: number; b?: string; c?: boolean }. But in reality, if a is there, b and c are there too — the same c.render call passes all three at once. Partial destroys that correlation:

if (props.a !== undefined) {
  props.b // still string | undefined
}

Checking a doesn't settle b, so users end up writing ?. that can never actually be undefined. The same happens with discriminated unions: after narrowing on props.kind === 'ng', props.error stays string | undefined.

On the union pattern

It's the more accurate type, but it breaks destructuring, which is what concerns me:

function Signup({ errors }: PageProps<'Signup'>) {}
//               ~~~~~~ TS2339: Property 'errors' does not exist on type '{ errors: {...} } | {}'

Practically every Inertia page component is written this way, including the examples in the official docs. If this starts requiring 'errors' in props, we end up with a type that is correct but that nobody actually uses.

Proposal: fill the empty member with optional never instead of {}

I think the root problem isn't "the value may be undefined" — it's that the union members don't share the same set of keys. {} has no errors key at all, so TypeScript refuses the access.

So instead of collapsing the empty member to {}, we could give it the same keys as the other members, made optional and typed never:

type AllKeys<T> = T extends unknown ? keyof T : never

type NormalizeProps<T, All = T> = T extends Record<string, never>
  ? { [K in AllKeys<Exclude<All, Record<string, never>>>]?: never }
  : T

Which yields:

{ errors: { email: string } } | { errors?: never }
  • The keys line up, so props.errors is accessible and destructuring works.
  • The value can still be undefined, so props.errors?.email is enforced.
  • The non-empty members are left completely untouched, so required stays required and narrowing keeps working.

That third point is the decisive difference from Partial. Partial rewrites { kind: 'ok' } into { kind?: 'ok' } and loses discrimination; this only inflates the empty member.

Measured comparison

Before Partial Union Proposal
Nonexistent prop is an error
props.errors?.email type lies
function Page({ errors })
With 3 props, checking one settles the rest
error: string after kind === 'ng'

I also verified:

  • Page rendered without props only → {} (since AllKeys resolves to never), which is what this PR intends.
  • Props containing defer{ items: number[] } | { items?: never }. Normalization runs after prop resolution, so there's no interference.
  • Existing unions with no empty member ({a} | {b}) → the condition doesn't match, so behavior is unchanged.

The trade-off

There's one way this loses to Partial: the type shown in the editor gets longer.

// Partial
{ errors?: { email: string } }

// Proposal
{ errors: { email: string } } | { errors?: never }

?: never isn't self-explanatory on first sight, so the implementation should carry a comment. But that's a one-time cost to understand, whereas the ?. noise is something users pay on every line. I'd rather prioritize how the code reads over how the type displays.

Other notes

  • Extracting MethodOutput, introducing Simplify, and distributing over C extends unknown all look good to me. The distribution in particular is the right move — it makes bare PageProps normalize per page.
  • Very minor: .changeset/fast-kings-reply.md is missing a trailing newline.

Summary

I'm on board with the direction. For the implementation I'd like to push for the optional never approach above rather than Partial or the union — it keeps both the ergonomics and the accuracy, and it leaves the existing union behavior alone.

@nkfr26
nkfr26 marked this pull request as ready for review August 25, 2026 14:30
@nkfr26

nkfr26 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

@ashunar0
Thank you for the review!
I think it's a wonderful way to compensate for each other's shortcomings! I adopted it as-is. Since it only affects empty objects, and the rule is consistent between the no-props case and the mixed case, I removed one test.
The description has also been updated. Please take a look when you have a chance!

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.

3 participants