Skip to content

bun-types: type Subprocess stdio as null and terminal as Terminal when spawned with the terminal option - #39350

Open
robobun wants to merge 1 commit into
mainfrom
farm/6a60f8fe/spawn-terminal-types
Open

bun-types: type Subprocess stdio as null and terminal as Terminal when spawned with the terminal option#39350
robobun wants to merge 1 commit into
mainfrom
farm/6a60f8fe/spawn-terminal-types

Conversation

@robobun

@robobun robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.spawn(["bash"], { terminal: { data() {} } }) types proc.stdout as ReadableStream, proc.stderr as undefined and, with stdin: "pipe", proc.stdin as FileSink, so proc.stdin.write(..) type-checks and throws; at runtime (bun 1.4.0) all three are null. proc.terminal is typed Terminal | undefined although it is always set, so the documented proc.terminal.write(..) (docs/runtime/child-process.mdx, and the option's own JSDoc example) does not compile, and bun's own tests write proc.terminal!.
  • Runtime: get_stdin / get_stdout / get_stderr in src/runtime/api/bun/subprocess.rs:577-606 return JSValue::NULL whenever a terminal is attached, get_terminal (:617) returns it, and js_bun_spawn_bindings.rs:888-899 overwrites stdio[0..3] with the PTY, so the stdio options have no effect. readable and writable are registered with the same getters (src/runtime/api/BunObject.classes.ts:71-76).
  • Declarations: Subprocess<In, Out, Err> (packages/bun-types/bun.d.ts:7542 on main) derives stdin / stdout / stderr / readable from the three stdio options only, terminal is a fixed Terminal | undefined (:7567), and the terminal option (:7436) is not reflected in spawn's signatures at all.

Fix

  • Subprocess gets a fourth type parameter, Term extends Terminal | undefined, which is the type of proc.terminal; stdin, stdout, stderr and readable are null when it is Terminal and keep their current types when it is undefined. Term = Terminal | undefined gives the union of both (stdout: ReadableStream | null, terminal: Terminal | undefined).
  • Bun.spawn picks Term with overloads, three per call form: options with a terminal property give Terminal; options without one (or with terminal: undefined) give undefined, as the existing two signatures now state explicitly; options whose terminal may be undefined (terminal: interactive ? {..} : undefined, or a value whose type declares it optional) fall through to a last pair that gives Terminal | undefined. Overloads rather than a type parameter inferred from the option because the usual terminal: { data(term, data) {..} } literal is context-sensitive: TypeScript fixes such a parameter to its default before it can infer from the literal (verified with a prototype: the literal was rejected and term / data became implicit any), and inference through an optional property also drops undefined, so the third case could not be told apart.
  • Default of the new parameter: undefined when the stdio type arguments are given (Subprocess<"pipe", "pipe", "pipe">, PipedSubprocess, Subprocess<any, "pipe", any>, ...), so every existing annotation keeps exactly its current property types; Terminal | undefined when all three are left at their defaults (Subprocess, Subprocess<any, any, any>, ReturnType<typeof Bun.spawn>), so the plain holder still accepts every process Bun.spawn returns, which is what docs/runtime/child-process.mdx's reference block has described since the terminal option landed (stdin: FileSink | number | undefined | null, terminal: Terminal | undefined). The docs need no change.
  • BaseOptions and SpawnOptions carry the same parameter (default undefined) so the Subprocess passed to onExit / ipc matches the returned one, and SpawnOptions<In, Out, Err> written as an annotation says whether it may carry a terminal (4th argument Terminal or Terminal | undefined; without it the property is rejected, as stdin: "pipe" is rejected by SpawnOptions<"ignore", ..> today). The option's type is spelled Term | (Term extends Terminal ? TerminalOptions : never) rather than as one conditional so that TypeScript measures the interface as covariant in Term; with a bare conditional it measures bivariant and a SpawnOptions<.., Terminal | undefined> value matched the terminal-less overload (found while prototyping; the fixture pins the assignability both ways). SpawnSyncOptions is untouched, so spawnSync still rejects terminal and its callbacks see terminal: undefined.
  • Overload order: the two catch-all signatures are last (they accept everything), the object form being the very last one, so that ReturnType<typeof Bun.spawn> is still exactly Subprocess and a mistyped one-argument call is reported against the object form. Side effect: Parameters<typeof Bun.spawn>[0] was the array form's string[] and is now the object form's options type; the one in-tree use (test/js/web/fetch/fetch-http2-client.test.ts:162) wanted the latter.
  • Blast radius, measured by type-checking test/ (7203 pre-existing diagnostics), src/js and src before and after: src is identical; src/js and most of test/ only change the wording or anchor line of diagnostics that were already there (possibly 'undefined' becomes possibly 'null' or 'undefined' on plain Subprocess holders; printed types gain the fourth argument; overload errors now name the offending property, e.g. the pre-existing error in src/js/node/child_process.ts:1410 now points at the two real mismatches instead of "'cmd' does not exist in type 'string[]'"). The one new kind of error is test/cli/watch/watch.test.ts:233/274/307, which passes a plain Subprocess variable to a Subprocess<"ignore", "pipe", any> parameter; that was accepted only because the conditional stdio types measure as bivariant, and is now rejected because the holder may hold a terminal process. Array-form mistakes now list the three two-argument overloads instead of one message (each line still carries the real error and the spelling suggestion); object-form mistakes get the one relevant message, where main listed both forms.
  • Verified: test/integration/bun-types/fixture/spawn.ts (appended below the lines the lib.dom case pins) asserts the canonical literal with data(term, data) callbacks, an existing Terminal, both call forms, stdio: [...] being overridden, the conditional case, onExit / ipc / spawnSync callbacks, explicit terminal: undefined, annotated SpawnOptions objects and their assignability, @ts-expect-error on proc.stdin.write / proc.stdout.getReader(), and the holder types (Subprocess, ReturnType<typeof Bun.spawn>, Subprocess<any, any, any, Terminal>, and that PipedSubprocess / ReadableSubprocess / WritableSubprocess / NullSubprocess keep their types and reject terminal processes). Against main's bun.d.ts it produces 52 diagnostics (listed below); USE_SYSTEM_BUN=1 bun test test/integration/bun-types/bun-types.test.ts passes all 16 cases with the fix (tsc 6 with and without lib.dom, and the fixture's own TypeScript 7), the pinned spawn.ts:62 / :107 diagnostics unchanged.
  • bun-types.test.ts gains the case that runs tsc over fixture/spawn.ts alone so debug builds, which skip the in-process cases, check it too: bun bd test test/integration/bun-types/bun-types.test.ts passes with the fix and fails on that case with packages/ stashed. The block is byte-identical to the one in bun-types: type Subprocess stdio properties by what the runtime exposes for each stdio option #39283, bun-types: type Subprocess.stderr as undefined when the stderr option is undefined #39297 and bun-types: drop undefined from the Null* subprocess aliases where the default is "pipe" #39303, so whichever lands first the others merge cleanly; all of them become redundant once test(bun-types): replace the tsgo and Bun.mmap spawns with one whole-fixture tsc run, enforced by a lint #39270 lands.
  • Related open PRs, not duplicates: bun-types: type Subprocess stdio properties by what the runtime exposes for each stdio option #39283 (stdio mapping arms) and bun-types: type Subprocess.stderr as undefined when the stderr option is undefined #39297 (ReadableToIO default for stderr) edit the same Subprocess property lines and pin the plain holder unions, which this PR extends with | null; bun-types: declare Subprocess.writable and lint interface Subprocess against the classes.ts table #39279 adds writable, which needs the same Term extends Terminal ? null : ... wrapper as stdin; bun-types: drop undefined from the Null* subprocess aliases where the default is "pipe" #39303 (Null* aliases) is unaffected. Whichever of these lands second has a small, mechanical conflict in those hunks. The stale "Only available on POSIX systems" line in the same JSDoc block predates this change and is tracked separately.

Background

  • Bun.spawn infers one type parameter per stdio slot from the literal options it is called with (stdin: "pipe" gives In = "pipe"), and Subprocess<In, Out, Err> turns each into a property type with a conditional type alias (WritableToIO<"pipe"> is FileSink, ReadableToIO<"inherit"> is undefined). With no type arguments the parameters are the full option unions and each property becomes the union of everything it can be; that instantiation is what code stores processes of unknown configuration in.
  • The terminal option (feat: add Bun.Terminal API for pseudo-terminal (PTY) support #25415) attaches a PTY; the child's three descriptors are the PTY's slave side, and the parent talks to it through Bun.Terminal (write() and the data callback), which is why the process exposes no streams for it.
  • TypeScript overload resolution tries the signatures in declaration order and takes the first whose parameter types accept the arguments, so a signature requiring terminal and one allowing only terminal?: undefined split the cases, and a signature that allows both has to come after them. When more than three signatures could apply and none does, TypeScript reports only the last one's error; ReturnType and Parameters of an overloaded function also use the last signature.
  • TypeScript decides whether Generic<A> is assignable to Generic<B> by first measuring how the type parameter is used (its variance) and comparing A with B accordingly, falling back to comparing the members only in some cases. A parameter used only inside conditional types and method parameters measures as bivariant (either direction passes); a parameter that also appears directly as a property type measures as covariant.
Runtime probe (bun 1.4.0, linux x64)
const p = Bun.spawn(["true"], { stdin: "pipe", stdout: "pipe", stderr: "pipe", terminal: { data() {} } });
console.log(p.stdin, p.stdout, p.stderr, p.readable, p.writable, p.terminal?.constructor?.name);
// null null null null null Terminal
const term = new Bun.Terminal({ data() {} });
const q = Bun.spawn(["true"], { terminal: term });
console.log(q.stdin, q.stdout, q.stderr, q.terminal === term); // null null null true
console.log(Bun.spawn(["true"], { stdout: "pipe" }).terminal); // undefined
Diagnostic shape for a mistyped option, main vs this PR (tsc 6.0.2)
Bun.spawn(["echo"], { stdoutt: "pipe" });
Bun.spawn({ cmd: ["echo"], stdoutt: "pipe" });

main:

errs.ts(1,23): error TS2561: Object literal may only specify known properties, but 'stdoutt' does not exist in type 'SpawnOptions<"ignore", "pipe", "inherit">'. Did you mean to write 'stdout'?
errs.ts(2,5): error TS2769: No overload matches this call.
  Overload 1 of 2, '(options: SpawnOptions<"ignore", "pipe", "inherit"> & { cmd: string[]; }): Subprocess<"ignore", "pipe", "inherit">', gave the following error.
    Object literal may only specify known properties, but 'stdoutt' does not exist in type 'SpawnOptions<"ignore", "pipe", "inherit"> & { cmd: string[]; }'. Did you mean to write 'stdout'?
  Overload 2 of 2, '(cmds: string[], options?: SpawnOptions<"ignore", "pipe", "inherit"> | undefined): Subprocess<"ignore", "pipe", "inherit">', gave the following error.
    Object literal may only specify known properties, and 'cmd' does not exist in type 'string[]'.

this PR:

errs.ts(1,23): error TS2769: No overload matches this call.
  Overload 1 of 6, '(cmds: string[], options?: SpawnOptions<"ignore", "pipe", "inherit", undefined> | undefined): Subprocess<"ignore", "pipe", "inherit", undefined>', gave the following error.
    Object literal may only specify known properties, but 'stdoutt' does not exist in type 'SpawnOptions<"ignore", "pipe", "inherit", undefined>'. Did you mean to write 'stdout'?
  Overload 2 of 6, '(cmds: string[], options: SpawnOptions<"ignore", "pipe", "inherit", Terminal> & { terminal: Terminal | TerminalOptions; }): Subprocess<...>', gave the following error.
    Object literal may only specify known properties, but 'stdoutt' does not exist in type 'SpawnOptions<"ignore", "pipe", "inherit", Terminal> & { terminal: Terminal | TerminalOptions; }'. Did you mean to write 'stdout'?
  Overload 3 of 6, '(cmds: string[], options: SpawnOptions<"ignore", "pipe", "inherit", Terminal | undefined>): Subprocess<"ignore", "pipe", "inherit", Terminal | undefined>', gave the following error.
    Object literal may only specify known properties, but 'stdoutt' does not exist in type 'SpawnOptions<"ignore", "pipe", "inherit", Terminal | undefined>'. Did you mean to write 'stdout'?
errs.ts(2,28): error TS2769: No overload matches this call.
  The last overload gave the following error.
    Object literal may only specify known properties, but 'stdoutt' does not exist in type 'SpawnOptions<"ignore", "pipe", "inherit", Terminal | undefined> & { cmd: string[]; }'. Did you mean to write 'stdout'?
Diagnostics the new fixture lines produce against main's bun.d.ts (52, selection)
spawn.ts(246,7): error TS18048: 'proc.terminal' is possibly 'undefined'.
spawn.ts(249,37): error TS2344: Type 'null' does not satisfy the constraint 'FileSink'.
spawn.ts(254,34): error TS2344: Type 'null' does not satisfy the constraint 'ReadableStream<Uint8Array<ArrayBuffer>>'.
spawn.ts(255,34): error TS2344: Type 'null' does not satisfy the constraint 'undefined'.
spawn.ts(257,33): error TS2554: Expected 2 arguments, but got 0.            // proc.terminal is not exactly Terminal
spawn.ts(260,3): error TS2578: Unused '@ts-expect-error' directive.         // proc.stdin.write(..) type-checks
spawn.ts(262,3): error TS2578: Unused '@ts-expect-error' directive.         // proc.stdout.getReader() type-checks
spawn.ts(293,34): error TS2344: Type 'ReadableStream<Uint8Array<ArrayBuffer>> | null' does not satisfy the constraint 'ReadableStream<Uint8Array<ArrayBuffer>>'.
spawn.ts(340,22): error TS7006: Parameter 'term' implicitly has an 'any' type.
spawn.ts(373,49): error TS2345: Argument of type 'null' is not assignable to parameter of type 'number | FileSink | undefined'.
spawn.ts(245,31): error TS2707: Generic type 'Subprocess<In, Out, Err>' requires between 0 and 3 type arguments.   // and 16 more of these

Totals by code: TS2707 x17, TS2344 x9, TS2554 x8, TS2578 x7, TS2345 x4, TS7006 x3, TS2314 x2, TS18048 x2.

… when Bun.spawn gets the terminal option

When Bun.spawn is given the terminal option, the stdin, stdout and stderr
getters return null and the terminal getter returns the Terminal, but the
declarations derived the stdio properties from the stdio options alone and
typed terminal as Terminal | undefined regardless.

Subprocess gains a fourth type parameter holding the type of its terminal
property (Terminal or undefined); the stdio properties and readable are
null when it is Terminal. Bun.spawn picks it with overloads: a present
terminal option gives Terminal, an absent one undefined, and an option that
may be undefined gives Terminal | undefined. The parameter defaults to
undefined when the stdio type arguments are given, so existing annotations
keep their types, and to Terminal | undefined when they are not, so a plain
Subprocess still accepts every process Bun.spawn returns. BaseOptions and
SpawnOptions carry the same parameter so the onExit and ipc callbacks see
the same process type, and a SpawnOptions annotation states whether it may
carry a terminal.

The spawn fixture asserts these shapes and bun-types.test.ts gains a case
that type-checks that fixture on debug builds as well.
@robobun
robobun requested a review from alii as a code owner August 16, 2026 10:26
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status: types-only fix for the terminal option's effect on Subprocess.

Reproduced by type-checking the new lines at the end of test/integration/bun-types/fixture/spawn.ts against main's bun.d.ts (52 diagnostics, among them 'proc.terminal' is possibly 'undefined', Type 'null' does not satisfy the constraint 'FileSink', and unused @ts-expect-error on proc.stdin.write(..)), and at runtime with bun 1.4.0: Bun.spawn(["true"], { stdin: "pipe", terminal: { data() {} } }) has stdin, stdout and stderr all null.

With this branch USE_SYSTEM_BUN=1 bun test test/integration/bun-types/bun-types.test.ts passes 16/16 and bun bd test on it passes the new fixture/spawn.ts type-checks case (which fails with packages/ reverted).

@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2e4c407e-dff6-489a-8487-a1412833153e

📥 Commits

Reviewing files that changed from the base of the PR and between 07d38c1 and 95aaf26.

📒 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

Included review availability: Your plan includes up to 5 reviews per rolling hour; 1 remains after this review.


Walkthrough

Changes

The declarations add terminal-state generics to spawn options, callbacks, and subprocesses. Bun.spawn overloads now return terminal-aware types. Integration tests compile terminal, optional-terminal, and compatibility cases against the packed type declarations.

Terminal spawn typing

Layer / File(s) Summary
Terminal-aware type contracts
packages/bun-types/bun.d.ts
Spawn options, callbacks, and Subprocess now carry terminal-state typing. Terminal-backed subprocesses expose null stdio properties and a typed terminal.
Terminal-aware Bun.spawn overloads
packages/bun-types/bun.d.ts
Bun.spawn distinguishes non-terminal, terminal, and optionally terminal-enabled calls for both object and array command forms.
Terminal spawn type validation
test/integration/bun-types/bun-types.test.ts, test/integration/bun-types/fixture/spawn.ts
The integration test compiles the spawn fixture against packed bun-types. The fixture checks terminal callbacks, stdio types, generic assignability, subtype compatibility, and spawnSync rejection.

Suggested reviewers: alii, jarred-sumner, dylan-conway

Merge Risk: ⚪ Minimal · up to 95aaf

The PR improves TypeScript accuracy for terminal-backed subprocesses and adds focused type tests; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: terminal-enabled subprocesses now expose null stdio and a typed Terminal.
Description check ✅ Passed The description explains the problem, fix, scope, and verification, and it covers the template requirements despite using different headings.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

I reviewed this PR and didn't find any bugs. Because it adds a fourth type parameter to the public Subprocess / SpawnOptions / BaseOptions interfaces and reshapes Bun.spawn's overload set — with acknowledged blast radius (new type errors in test/cli/watch/watch.test.ts, changed diagnostic shapes, Parameters<typeof Bun.spawn>[0] flipping forms) and several sibling PRs touching the same lines — a human look at the API design would still be worthwhile.

What was reviewed:

  • Overload ordering and the Term = undefined / Terminal / Terminal | undefined split against the fixture cases; the terminal-less overloads correctly stay first so existing calls without terminal keep their exact types.
  • The conditional default for Term on SubprocessSubprocess<any, any, any> and bare Subprocess get Terminal | undefined, while any narrowed stdio arg gives undefined, which is what the fixture pins.
  • The Term | (Term extends Terminal ? TerminalOptions : never) spelling for covariance, and that SpawnSyncOptions still extends BaseOptions<In, Out, Err> (implicit Term = undefined), so spawnSync continues to reject terminal.
  • The new bun-types.test.ts block matches the existing Bun.mmap pattern in the same file.
Extended reasoning...

Overview

This is a types-only change to packages/bun-types/bun.d.ts plus fixture and test additions. It threads a fourth type parameter Term extends Terminal | undefined through BaseOptions, SpawnOptions and Subprocess, so that when Bun.spawn is called with the terminal option the returned process types stdin/stdout/stderr/readable as null and terminal as Terminal (matching runtime behavior in src/runtime/api/bun/subprocess.rs). Bun.spawn grows from 2 to 6 overloads to pick Term from the call site. ~180 lines of fixture assertions in spawn.ts pin the inference for both call forms, the conditional-terminal case, onExit/ipc callbacks, annotated SpawnOptions values, and the holder-type assignability in both directions. A new bun-types.test.ts case runs tsc over fixture/spawn.ts alone so debug builds check it too.

Security risks

None. This is .d.ts only — no runtime code, no native code, no I/O.

Level of scrutiny

Medium-high, as an API-design change rather than a correctness risk. The runtime behavior being modeled is verified and the fixture coverage is very thorough. But this widens the public type surface of three heavily-used interfaces, uses a non-obvious conditional default ([Writable, Readable, Readable] extends [In, Out, Err] ? Terminal | undefined : undefined) whose intent is that specifying any stdio arg opts you out of the terminal union, and relies on a variance trick that is documented in a comment but is the kind of thing a maintainer should agree to. The PR itself reports it introduces three new type errors in test/cli/watch/watch.test.ts and changes the shape of overload error messages for array-form mistakes (now 3 candidates listed). Per the repo's review guidance, adding or changing user-facing API surface warrants a maintainer look.

Other factors

  • The deprecated OptionsObject alias is unchanged (still 3 params), so it no longer forwards a Term — that seems intentional given it's deprecated, but worth a maintainer glance.
  • Four related open PRs (#39283, #39297, #39279, #39303) edit the same Subprocess property lines; the description calls out the mechanical conflicts and that #39270 will supersede the duplicated test block. Landing order is a maintainer call.
  • No prior human review on the thread; only the robobun status comment. Given the design-level decisions (overloads vs. inferred parameter, the conditional default's exact trigger, whether the new watch.test.ts errors should be fixed in this PR), I'm deferring rather than approving.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

On the OptionsObject point: leaving it at three parameters is deliberate. It aliases BaseOptions, which has never had a terminal property (the property lives on SpawnOptions), so there is no terminal state for it to forward: BaseOptions' Term defaults to undefined, its onExit / ipc callbacks see Subprocess<In, Out, Err, undefined> as before, and a value of that type has no terminal property, so Bun.spawn resolves it to the terminal-less overload and returns Subprocess<In, Out, Err, undefined>, which agrees with the callbacks. Checked with tsc 6.0.2 against this branch:

const opts: Bun.SpawnOptions.OptionsObject<"pipe", "pipe", "pipe"> = {};
Bun.spawn(["cat"], opts); // Subprocess<"pipe", "pipe", "pipe", undefined>

The design questions (overloads instead of an inferred parameter, what triggers the Terminal | undefined default, and whether to touch the three watch.test.ts lines here) are the ones the description lays out for a maintainer; I have left watch.test.ts alone because test/ is not type-checked in CI and the change there is unrelated to this fix.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:58 AM PT - Aug 16th, 2026

@robobun, your commit 95aaf26dc5fe0ca576d1fd71ecf734f2e9d8f124 passed in Build #99407! 🎉


🧪   To try this PR locally:

bunx bun-pr 39350

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

bun-39350 --bun

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.

2 participants