Skip to content

feat(next-typed-href): require explicit type arguments on routes()#82

Open
akameco wants to merge 5 commits into
mainfrom
feat/typed-href-require-routes-args
Open

feat(next-typed-href): require explicit type arguments on routes()#82
akameco wants to merge 5 commits into
mainfrom
feat/typed-href-require-routes-args

Conversation

@akameco
Copy link
Copy Markdown
Contributor

@akameco akameco commented May 26, 2026

Summary

  • defineTypedHref.routes() / defineTypedHrefWithNuqs.routes() を型引数なしで呼べないように変更
  • エラーは .routes() 呼び出し行自体 に出る (downstream の .$href(...) ではなく)
  • 型レベルのみの変更 — ランタイム挙動は変わらない

背景

.routes() には型引数 <Routes, RouteParamsMap> が必要だが、省略しても TypeScript は Routes を制約上限 string として推論し、結果的に型安全性が静かに失われていた

// before: 動くが型安全性ゼロ
const { $href } = defineTypedHref.routes();
$href({ route: "/anything-goes" });  // ✅ コンパイル通る (悪い意味で)

このPRで .routes() 行に直接エラーを出して早期検知する。

// after
defineTypedHref.routes();
// ❌ Expected 1 arguments, but got 0.   ← この行で停止

実装

common/types.ts に汎用ヘルパを追加:

export type RequireExplicitRoutesArgs<Routes extends string> = string extends Routes
  ? [
      pass_Routes_and_RouteParamsMap_as_type_arguments:
        "routes<Routes, RouteParamsMap>() requires explicit type arguments",
    ]
  : [];

routes のシグネチャに conditional rest parameter として適用:

type TypedHrefBuilder = {
  routes: <R extends string, M extends Record<R, ...>>(
    ...args: RequireExplicitRoutesArgs<R>
  ) => { $href: ... };
};

仕組み:

  • ユーザーが .routes<MyRoutes, MyMap>() と書く → Routes = MyRoutes → rest tuple が [] → 引数 0 で OK
  • ユーザーが .routes() と書く → Routes = string (制約上限) → rest tuple が [phantom_arg] → 引数 0 で "Expected 1 arguments, but got 0"

なぜ rest parameter にしたか

最初は returns 型を error brand object に置き換える案 (RequireExplicitRoutes<R, B>{ __error: "..." }) を試したが、以下の欠点があった:

  • エラーが downstream の .$href(...) で発火するため、ユーザーは .routes() まで OK と誤解しがち
  • ランタイム impl との型ズレを埋めるため as unknown as TypedHrefBuilder["routes"] のキャストが必要

rest parameter 案ではエラーが .routes() 行に出る上、ランタイム impl のシグネチャと型宣言が自然に一致するためキャスト不要

IDE 体験

ホバーすると以下が見える:

routes<R, M>(
  pass_Routes_and_RouteParamsMap_as_type_arguments:
    "routes<Routes, RouteParamsMap>() requires explicit type arguments"
): { $href: ... }

パラメータが指示、パラメータ (string literal) がメッセージという役割分離。

Breaking change?

技術的には型レベルの破壊的変更だが、.routes() を型引数なしで呼んでいたコードは型安全性が崩れた状態だったので、修正されるべきもの。changeset は minor 扱い。

Test plan

  • pnpm --filter @plainbrew/next-typed-href test run — 52テスト全通過
  • defineTypedHref.routes()// @ts-expect-error が成立すること (TS2554: Expected 1 arguments, but got 0)
  • defineTypedHref.routes<R, M>() 明示時に通常の builder が返ること
  • defineTypedHrefWithNuqs 側も同様の挙動

🤖 Generated with Claude Code

@akameco akameco marked this pull request as ready for review May 26, 2026 04:38
@akameco akameco force-pushed the feat/typed-href-require-routes-args branch 2 times, most recently from 2583a4a to a840c34 Compare May 26, 2026 05:10
@akameco akameco requested a review from amotarao May 27, 2026 00:34
Copy link
Copy Markdown
Member

@amotarao amotarao left a comment

Choose a reason for hiding this comment

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

ちょいコメント

Comment on lines +66 to +70
export type RequireExplicitRoutesArgs<Routes extends string> = string extends Routes
? [
pass_Routes_and_RouteParamsMap_as_type_arguments: "routes<Routes, RouteParamsMap>() requires explicit type arguments",
]
: [];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

型エラーメッセージが意図通りでてなさそうな気がする

src/index.test.ts:139:21 - error TS2554: Expected 1 arguments, but got 0.

139     defineTypedHref.routes();
                        ~~~~~~

  src/index.ts:28:5
    28     ...args: RequireExplicitRoutesArgs<Routes>
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    Arguments for the rest parameter 'args' were not provided.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ご指摘ありがとうございます!

当初 ...args: だったため tsc が args という generic 名しか出力せず、せっかくの説明が埋もれていました。rest パラメータ名自体を説明的にする形で解消しています。

routes: <Routes, RouteParamsMap>(
  ...typeArguments: RequireExplicitRoutesArgs<Routes>
) => ...

実際の tsc --pretty 出力:

check.ts:4:17 - error TS2554: Expected 1 arguments, but got 0.

4 defineTypedHref.routes();
                  ~~~~~~

  src/index.ts:63:5
    63     ...typeArguments: RequireExplicitRoutesArgs<Routes>
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    Arguments for the rest parameter 'typeArguments' were not provided.

詳細な指示メッセージ ("routes<Routes, RouteParamsMap>() requires explicit type arguments") は型リテラルとして残し、IDE hover で参照できる形にしています。

スクリーンショット 2026-05-27 14 12 35

将来の degrade 防止に、TS Compiler API でコンパイルしてエラーテキストを assert するテストも追加しました (src/routes-error-message.test.ts)。こちらは不要であれば削除してもいいかなと思います。

akameco added 4 commits May 27, 2026 10:45
`defineTypedHref.routes()` and `defineTypedHrefWithNuqs.routes()` now
fail to compile when called without explicit `<Routes, RouteParamsMap>`
type arguments. Without this, `Routes` would silently default to its
constraint upper bound `string`, defeating the type-safety the library
promises.

Implementation uses a conditional rest parameter via a shared
`RequireExplicitRoutesArgs<Routes>` helper:

  string extends Routes
    ? [pass_Routes_and_RouteParamsMap_as_type_arguments: "...message..."]
    : []

`string extends Routes` is true only when no explicit type argument was
supplied. Missing the now-required argument fails the call AT the
`.routes()` site with "Expected 1 arguments, but got 0", and hover
reveals the parameter name and the literal-string message explaining
the fix. Valid call sites with a literal-union `Routes` stay nullary.

Type-level only; no runtime behavior changes.
…S error

`...args` shows up as the literal word `args` in TS's "Arguments for the rest
parameter 'args' were not provided." message, hiding the explanatory parameter
name that was supposed to do the teaching. Renaming the rest binding itself
puts the guidance directly in the error text — both at the .routes() call site
and in the related-info pointer.
…ter name

The whole point of naming the rest parameter `pass_Routes_and_RouteParamsMap_as_type_arguments`
is that TS prints it back to the user as part of "Arguments for the rest parameter 'X'
were not provided." A future rename to something generic like `args` would silently strip
the guidance from the error text without breaking any compile-time check.

Compile small snippets via the TypeScript compiler API and assert the formatted
diagnostic text contains the expected parameter name, for both defineTypedHref
and defineTypedHrefWithNuqs.
- types.ts: trim RequireExplicitRoutesArgs JSDoc — drop the mechanism walkthrough
  (self-evident from the type body) and clarify that the inner string literal is
  hover-only, while the rest-param name is what tsc actually prints.
- routes-error-message.test.ts:
  - dedupe the two builder snippets via test.each
  - swap lib.dom.d.ts → lib.webworker.d.ts (URLSearchParams still present, ~26%
    faster: 430ms → 317ms test phase)
  - drop dead fileExists/readFile host overrides (compiler never calls them
    once getSourceFile returns a SourceFile for the virtual file)
  - rename __virtual__check__.ts → snippet.ts to match repo naming conventions
@akameco akameco force-pushed the feat/typed-href-require-routes-args branch from a840c34 to a7f1ace Compare May 27, 2026 02:38
`pass_Routes_and_RouteParamsMap_as_type_arguments` (47 chars, snake_case)
was load-bearing for the error message but unidiomatic in TS and hard to
read in the diagnostic. The inner tuple-element type literal already
carries the full guidance ("routes<Routes, RouteParamsMap>() requires
explicit type arguments"), which is surfaced on IDE hover.

Shorten the rest-param to `typeArguments` (13 chars, camelCase). The error
text now reads "Arguments for the rest parameter 'typeArguments' were not
provided." — concise and scannable, with the verbose explanation still
available on hover and in the related-info pointer.
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.

2 participants