Skip to content

fix(openapi): stop JsonifiedValue from turning interface outputs into unknown - #2109

Merged
dinwwwh merged 2 commits into
middleapi:mainfrom
dinwwwh:claude/jsonified-value-type-issue-0c9b63
Sep 28, 2026
Merged

dinwwwh merged 2 commits into
middleapi:mainfrom
dinwwwh:claude/jsonified-value-type-issue-0c9b63

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

JsonifiedClient typed interface outputs as unknown, while the same shape written as a type alias worked. Interface outputs, and interfaces nested inside other outputs, now resolve to their JSON types (e.g. Date becomes string). Type-checking stays as cheap as before.

interface User { id: number, createdAt: Date }

type Before = JsonifiedValue<User> // unknown
type After = JsonifiedValue<User>  // { id: number, createdAt: string }

Why

Plain objects were detected with T extends Record<string, unknown>. TypeScript gives an implicit index signature only to object literal types, never to interfaces or class instances (microsoft/TypeScript#15300). So interfaces fell through every branch to unknown.

Fixes

  • Interface and class-instance outputs map their properties the same way type aliases do.
  • readonly arrays and tuples, ReadonlyMap, and ReadonlySet resolve like their mutable counterparts instead of becoming unknown.
  • Functions and constructors still resolve to unknown, and built-ins (Date, Map, Set, Blob, event iterators) behave as before.
  • Other objects without a dedicated branch (class instances, Error, RegExp, ...) are mapped by their properties like interfaces, since TypeScript cannot tell them apart structurally.

Performance

Plain objects skip the costly infer branches, so resolving outputs costs about the same as before: in a 400-object benchmark, ~92k instantiations vs ~95k on main. Without the extra check the fix would have taken ~185k.

Testing

  • New type tests for interfaces at the top level, nested in objects and arrays, in JsonifiedClient outputs, for callable interfaces, and for readonly arrays, tuples, maps, and sets. They fail on main and pass here.
  • pnpm type:check passes; eslint is clean on the changed files.

… unknown

JsonifiedValue detected plain objects with `T extends Record<string, unknown>`,
which interfaces and class instances never satisfy because TypeScript only gives
object literal types an implicit index signature. Interface outputs (and nested
interface properties) fell through every branch to `unknown`.

Match plain objects with `object` as the last branch instead, after the built-in
cases, and gate the costly `infer` branches behind a single union check so plain
objects skip them.
@pkg-pr-new

pkg-pr-new Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2109

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2109

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2109

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2109

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2109

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2109

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@2109

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2109

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2109

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2109

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2109

@orpc/experimental-msw

npm i https://pkg.pr.new/@orpc/experimental-msw@2109

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2109

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2109

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2109

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2109

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2109

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2109

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2109

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2109

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2109

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2109

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2109

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2109

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2109

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2109

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2109

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2109

commit: cf48d62

@codecov

codecov Bot commented Sep 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 30 untouched benchmarks


Comparing dinwwwh:claude/jsonified-value-type-issue-0c9b63 (cf48d62) with main (dfa4e1e)

Open in CodSpeed

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ Minor suggestions only — the interface fix itself is correct and well tested.

Reviewed changes

  • Rewrote JsonifiedValue branch order — primitives, Date | bigint | URL, and Array are checked first, then a non-inferring allowlist guard (Blob | Map | Set | AsyncIteratorObject | Function) selects the old infer branches, then T extends object maps the rest. This replaces T extends Record<string, unknown>, which interfaces never satisfied.
  • New type tests — interface at the top level, nested in objects/arrays, a JsonifiedClient interface output, and callable-interface → unknown. Verified these fail on the base rev (only types.ts reverted) and pass here.
  • pnpm type:check passes (root tsc covers *.test-d.ts); eslint is clean on both files.

ℹ️ Nitpicks

  • JsonifiedValue<readonly Date[]> now resolves to readonly string[] (the PR body calls this out), but no test covers readonly arrays. Consider adding one next to the interface cases.
  • readonly tuples bypass JsonifiedArray's undefined → null normalization: JsonifiedValue<readonly [Date, undefined]> is readonly [string, undefined], while the mutable [Date, undefined] becomes [string, null]. Harmless but inconsistent.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread packages/openapi/src/types.ts
ReadonlyMap and ReadonlySet values are Map and Set instances at runtime, so
they serialize to entry arrays; they now type as such instead of mapped object
shapes. Readonly arrays and tuples go through JsonifiedArray, so readonly tuples
get the same undefined-to-null normalization as mutable ones.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

This run re-reviews the delta since the prior pullfrog review (122ac80), which was the single commit cf48d62 adding readonly-type coverage on top of the interface fix.

  • Readonly primitives now follow their mutable counterparts — the Array guard became ReadonlyArray, so readonly Date[] resolves to string[] instead of falling through to the mapped branch.
  • ReadonlyMap/ReadonlySet now serialize to entry arrays — the allowlist and inner infer branches widened from Map/Set to ReadonlyMap/ReadonlySet, matching the runtime identity of those values.
  • JsonifiedArray constraint widened to ReadonlyArray — readonly tuples route through the same path as mutable ones, so readonly [Date, undefined] gets the undefined → null normalization ([string, null]) that mutable tuples already had.
  • Tests added for ReadonlyMap, ReadonlySet, readonly Date[], and readonly [Date, undefined]; they fail on the parent commit and pass here. pnpm type:check passes at head.

The remaining structural mapping of Error/RegExp/Promise is an intentional, documented decision (plain data can structurally extend Error, so special-casing it would break ordinary outputs), so the prior inline thread on built-ins is now resolved.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit 1d40246 into middleapi:main Sep 28, 2026
11 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.

1 participant