Skip to content

bun-types: type Subprocess.stderr as undefined when the stderr option is undefined - #39297

Open
robobun wants to merge 2 commits into
mainfrom
farm/65862098/spawn-stderr-undefined-type
Open

bun-types: type Subprocess.stderr as undefined when the stderr option is undefined#39297
robobun wants to merge 2 commits into
mainfrom
farm/65862098/spawn-stderr-undefined-type

Conversation

@robobun

@robobun robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.spawn(cmd, { stderr: undefined }) types proc.stderr as ReadableStream<Uint8Array<ArrayBuffer>>; at runtime (bun 1.4.0) it is undefined, so proc.stderr.text() type-checks and throws. The same happens with stdio: [.., .., undefined], with the Subprocess passed to onExit / ipc, with Subprocess<In, Out, "pipe" | undefined>["stderr"] (a plain ReadableStream, also what stderr: cond ? "pipe" : undefined infers under exactOptionalPropertyTypes), and with NullSubprocess["stderr"] (ReadableStream | undefined).
  • Cause: Spawn.ReadableToIO (packages/bun-types/bun.d.ts:7439) maps the option type "pipe" | undefined to ReadableStream, and Subprocess uses it for both stdout and stderr (bun.d.ts:7548-7549). An undefined option means "use this slot's default", and the two slots have different defaults: the runtime starts from [Ignore, Pipe, Inherit] for spawn (src/runtime/api/bun/js_bun_spawn_bindings.rs:361-366), an undefined option leaves that default in place (spawn/stdio.rs:422-424), and an inherited stream reads back as undefined (subprocess/Readable.rs:266). The alias dates from when the declarations modelled the stderr default as "pipe" (bun-types: infer strict Subprocess from Bun.spawn() options #1501); bun-types: infer strict Subprocess from Bun.spawn() options, part 2 #2573 and later Improve types and autocomplete for Bun.spawn (fixes #17274) #19162 changed the omitted-option default to "inherit" without touching this arm.
  • The JSDoc on the stdio, stdout and stderr options made the same claim ("pipe", undefined: the process has a ReadableStream).

Fix

Background

  • Bun.spawn infers one type parameter per stdio slot (In, Out, Err) from the stdin / stdout / stderr options or the stdio tuple. A parameter whose option is omitted takes the declared default ("ignore", "pipe", "inherit"); an option that is present but undefined makes the parameter undefined itself. Subprocess<In, Out, Err> then derives each property's type with a conditional type alias, which is therefore the place where an undefined parameter has to be turned back into that slot's default.
  • These aliases are distributive: applied to a union they map each member and union the results. That is why "pipe" | undefined becomes ReadableStream | undefined for stderr, and why the unconstrained holder type Subprocess (every option at once) still lists every possible value.
Diagnostics the new fixture lines produce against the unfixed bun.d.ts
spawn.ts(241,34): error TS2344: Type 'undefined' does not satisfy the constraint 'ReadableStream<Uint8Array<ArrayBuffer>>'.
spawn.ts(245,34): error TS2344: Type 'undefined' does not satisfy the constraint 'ReadableStream<Uint8Array<ArrayBuffer>>'.
spawn.ts(257,34): error TS2344: Type 'undefined' does not satisfy the constraint 'ReadableStream<Uint8Array<ArrayBuffer>>'.
spawn.ts(265,34): error TS2344: Type 'ReadableStream<Uint8Array<ArrayBuffer>> | undefined' does not satisfy the constraint 'ReadableStream<Uint8Array<ArrayBuffer>>'.
  Type 'undefined' is not assignable to type 'ReadableStream<Uint8Array<ArrayBuffer>>'.
spawn.ts(271,38): error TS2344: Type 'undefined' does not satisfy the constraint 'ReadableStream<Uint8Array<ArrayBuffer>>'.
spawn.ts(274,38): error TS2344: Type 'undefined' does not satisfy the constraint 'ReadableStream<Uint8Array<ArrayBuffer>>'.
spawn.ts(290,7): error TS2344: Type 'ReadableStream<Uint8Array<ArrayBuffer>> | undefined' does not satisfy the constraint 'ReadableStream<Uint8Array<ArrayBuffer>>'.
  Type 'undefined' is not assignable to type 'ReadableStream<Uint8Array<ArrayBuffer>>'.
spawn.ts(294,44): error TS2554: Expected 2 arguments, but got 0.
spawn.ts(300,16): error TS2314: Generic type 'ReadableToIO' requires 1 type argument(s).
spawn.ts(302,15): error TS2314: Generic type 'ReadableToIO' requires 1 type argument(s).
Runtime probe (bun 1.4.0, linux x64)
spawn     stderr: undefined                 -> undefined
spawn     stdout: undefined                 -> ReadableStream
spawn     stdio: [undefined x3]             -> stdin undefined, stdout ReadableStream, stderr undefined
spawnSync stdout/stderr: undefined          -> Buffer, Buffer
spawnSync stdio: ["ignore", undef, undef]   -> Buffer, Buffer

… is undefined

Spawn.ReadableToIO mapped an undefined option to ReadableStream for both
stdout and stderr, but an undefined option means the slot's default, and
Bun.spawn defaults stderr to "inherit" (stdout to "pipe"). So
Bun.spawn(cmd, { stderr: undefined }).stderr type-checked as a
ReadableStream while being undefined at runtime.

ReadableToIO now takes the slot's default as a second type parameter and
resolves an undefined option through it. The parameter defaults to
"pipe", so the one-parameter form keeps its meaning; Subprocess passes
"pipe" for stdout/readable and "inherit" for stderr. ReadableToSyncIO is
unchanged because spawnSync defaults both slots to "pipe".

The fixture asserts the undefined option for stdout and stderr through
both spawn overloads, the stdio tuple, the onExit/ipc callback argument,
explicit type arguments, NullSubprocess, and spawnSync, and a tsc run
over fixture/spawn.ts is added so debug builds check it too.
@robobun
robobun requested a review from alii as a code owner August 16, 2026 04:18
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced at the type level against the current bun.d.ts (tsc 6.0.2 and tsgo): Bun.spawn(["cat"], { stderr: undefined }).stderr is typed ReadableStream<Uint8Array<ArrayBuffer>>, and at runtime (bun 1.4.0) it is undefined because an undefined option keeps the slot's default and spawn's stderr default is "inherit". The new assertions in test/integration/bun-types/fixture/spawn.ts produce 10 diagnostics without the bun.d.ts change and none with it, under both the release test cases and the debug-build tsc case (bun bd test test/integration/bun-types/bun-types.test.ts).

Fix is in this PR; see the description for the reasoning and the related open PRs (#39283, #39279, #39270) that touch the same lines.

@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e8be2905-f359-4933-961b-08f9e538c5b3

📥 Commits

Reviewing files that changed from the base of the PR and between 83d65fa and e0e7b62.

📒 Files selected for processing (3)
  • packages/bun-types/bun.d.ts
  • test/integration/bun-types/bun-types.test.ts
  • test/integration/bun-types/fixture/spawn.ts

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

@claude claude 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.

LGTM — types-only fix aligning Subprocess.stderr's type with runtime behavior when the option is undefined.

What was reviewed:

  • ReadableToIO<X, Default> change: distributivity over unions, the Exclude<Readable, undefined> constraint on Default (prevents recursion), and backward compat of the single-arg form — all hold.
  • Runtime defaults confirmed against js_bun_spawn_bindings.rs:361-366 ([Ignore, Pipe, Inherit]; sync overrides both to Pipe).
  • Holder types Subprocess["stderr"] / WritableSubprocess["stderr"] stay ReadableStream | number | undefined; NullSubprocess["stderr"] narrowing to undefined is correct.
  • New bun-types.test.ts case follows the existing Bun.mmap block pattern; fixture assertions cover both overloads, stdio tuple, onExit/ipc, spawnSync, and explicit type args.
Extended reasoning...

Overview

This PR fixes a type-vs-runtime mismatch in packages/bun-types/bun.d.ts: when stderr: undefined is passed to Bun.spawn, the runtime falls back to the slot default ("inherit"), so proc.stderr is undefined — but the types previously said ReadableStream. The fix adds a second Default type parameter to ReadableToIO so the undefined arm resolves to the slot's actual default; Subprocess.stderr passes "inherit", stdout/readable pass "pipe". JSDoc on the stdio/stdout/stderr options is updated to stop claiming undefined means pipe. Fixture assertions and a debug-build tsc case are added.

Security risks

None. This is a .d.ts-only change plus type-check test fixtures; no compiled runtime code is touched.

Level of scrutiny

Low-to-medium. Type declarations for a public API — the risk is breaking existing user code that type-checked before. I checked the specific concerns: the single-arg ReadableToIO<X> keeps its old meaning (Default defaults to "pipe"); the unconstrained Subprocess["stderr"] and WritableSubprocess["stderr"] unions are unchanged (pinned in the fixture); NullSubprocess["stderr"] narrows from ReadableStream | undefined to undefined, which is a strict correction (none of "ignore" | "inherit" | null | undefined produces a stream at runtime for stderr). The Default extends Exclude<Readable, undefined> constraint guarantees the recursive arm terminates.

Other factors

The runtime claim was verified directly against src/runtime/api/bun/js_bun_spawn_bindings.rs:361-366: async spawn starts from [Ignore, Pipe, Inherit] and spawnSync sets both output slots to Pipe, matching the PR's mapping (and why ReadableToSyncIO is left alone). The new test in bun-types.test.ts is byte-for-byte the same shape as the existing Bun.mmap debug-build check just above it. The fixture coverage is thorough — both spawn overloads, the stdio tuple with literal and union undefined, onExit/ipc, explicit Subprocess<...> args, spawnSync, and the alias itself — and the PR description documents that the fixture fails (10 diagnostics) against the unfixed declarations. No outstanding reviewer comments.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:21 AM PT - Aug 16th, 2026

@robobun, your commit e0e7b620eaf69ba37b7f6b582a51b1f8ecb2d399 passed in Build #99365! 🎉


🧪   To try this PR locally:

bunx bun-pr 39297

That installs a local version of the PR into your bun-39297 executable, so you can run:

bun-39297 --bun

@claude claude 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.

LGTM — types-only fix aligning Subprocess.stderr with runtime behavior when the option is undefined.

What was reviewed:

  • ReadableToIO's new Default parameter distributes correctly over unions; NullSubprocess["stderr"], WritableSubprocess["stderr"] (the any path) and unconstrained Subprocess["stderr"] all resolve as the fixture asserts.
  • Backward compat: one-arg ReadableToIO<X> keeps its old meaning via Default = "pipe"; ReadableToSyncIO and WritableToIO are correctly left alone.
  • New fixture lines are appended, so the pinned spawn.ts:62/:107 diagnostics in the lib.dom case don't shift; the new Bun.spawn describe block mirrors the existing Bun.mmap block byte-for-byte in shape.
Extended reasoning...

Overview

This PR fixes a type-vs-runtime mismatch in packages/bun-types/bun.d.ts: Bun.spawn(cmd, { stderr: undefined }) typed proc.stderr as ReadableStream when the runtime returns undefined (because an explicit undefined option keeps the slot's default, and stderr's default for spawn is "inherit"). The fix adds a second Default parameter to Spawn.ReadableToIO so undefined recurses into the slot's actual default, and Subprocess.stderr passes "inherit" while stdout/readable pass "pipe". JSDoc on the stdio/stdout/stderr options is corrected to match. The test file gains a debug-build-friendly tsc case and the fixture gains ~75 lines of type assertions covering both spawn overloads, the stdio tuple, onExit/ipc callbacks, spawnSync, the holder unions, and the alias itself.

Security risks

None. This is a .d.ts type-declaration and JSDoc change plus type-check fixtures; nothing here executes at runtime or affects any security-sensitive path.

Level of scrutiny

Low-to-medium. Types-only changes cannot break runtime behavior; the risk is limited to (a) breaking existing user type-checks and (b) the new fixture assertions being wrong. For (a), Default defaults to "pipe", so the one-parameter form ReadableToIO<X> is unchanged, and I traced the distributive-conditional evaluation for NullSubprocess["stderr"] (all four union members → undefined), WritableSubprocess["stderr"] (any → both branches → ReadableStream | number | undefined), and unconstrained Subprocess["stderr"] (full Readable union → same) — each matches what the fixture pins. For (b), the assertions match the runtime probe in the PR description and the runtime source references ([Ignore, Pipe, Inherit] defaults for spawn, both "pipe" for spawnSync).

Other factors

  • The new fixture lines are appended after the last existing block, so the line-pinned lib.dom diagnostics at spawn.ts:62:38 and spawn.ts:107:38 in bun-types.test.ts remain valid; the new assertions use the same expectType(...).is<ReadableStream<...>>() shape that already appears earlier in the fixture without producing lib.dom diagnostics.
  • The new describe("Bun.spawn") block is structurally identical to the existing describe("Bun.mmap") block right above it (same tsconfig setup, same tsc spawn, same stdout/stderr/exitCode assertion order), and the PR notes it is byte-identical to the same block in two related open PRs so they merge cleanly.
  • tsd.expectType<T>() (zero-arg) and .is<X> are both supported by fixture/utilities.ts, and Bun.Spawn is the real namespace name (SpawnOptions is an export import alias), so Bun.Spawn.ReadableToIO in the fixture resolves.
  • No prior human reviews or unaddressed comments on the timeline; the bug-hunting pass found nothing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant