Skip to content

Fix: Accept capture streams in RenderOptions without a type assertion - #981

Open
tomaspiaggio wants to merge 2 commits into
vadimdemedes:masterfrom
tomaspiaggio:fix/render-options-stream-types
Open

Fix: Accept capture streams in RenderOptions without a type assertion#981
tomaspiaggio wants to merge 2 commits into
vadimdemedes:masterfrom
tomaspiaggio:fix/render-options-stream-types

Conversation

@tomaspiaggio

@tomaspiaggio tomaspiaggio commented Jul 29, 2026

Copy link
Copy Markdown

Ink can already render somewhere other than a terminal. debug: true exists for it, ink-testing-library is built on it, and the readme documents stdout as a stream.Writable. The types are the only thing standing in the way.

The problem

RenderOptions asks for NodeJS.WriteStream and NodeJS.ReadStream. Those are tty.WriteStream and tty.ReadStream, which extend net.Socket — around 90 members each, from cursorTo and moveCursor to remoteAddress and setNoDelay. So the moment you want to capture output instead of printing it, you're stuck:

const output: string[] = [];

const sink = {
	columns: 80,
	write(data: string) {
		output.push(data);
	},
};

render(<App />, {stdout: sink, debug: true});
// ✗ error TS2740: Type '{ columns: number; write(data: string): void; }' is missing
//   the following properties from type 'WriteStream': clearLine, clearScreenDown,
//   cursorTo, moveCursor, and 97 more.

The way around it today is to assert the type away:

render(<App />, {stdout: sink as unknown as NodeJS.WriteStream});

That's not a small thing to ask. as unknown as doesn't just permit this object, it switches off type checking for that value entirely — including future mistakes that genuinely matter, like getting write's shape wrong. It also can't be used at all in codebases that ban the pattern by lint rule, which puts a documented Ink feature out of reach for them.

And Ink's own code has to do it. ink-testing-library casts internally. So does this repository:

// test/helpers/create-stdout.ts
const stdout = new EventEmitter() as unknown as FakeStdout;

// test/render.tsx
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;

When the companion package and the library's own tests both have to lie to the compiler to use a supported feature, the type is the thing that's wrong.

What Ink actually needs

Before designing anything I checked what Ink really touches, from the compiled output:

$ grep -ohE "stdout\.[a-zA-Z_]+" build/*.js build/components/*.js build/hooks/*.js | sort -u
columns  destroyed  isTTY  off  on  writable  writableEnded  writableLength  write  _writableState

$ grep -ohE "stdin\.[a-zA-Z_]+"  build/*.js build/components/*.js build/hooks/*.js | sort -u
addListener  isTTY  on  read  ref  removeListener  setEncoding  setRawMode  unref  unshift

Ten members out of ninety, and most of them conditional:

  • write() is the only one always used.
  • The other output members are already read defensively — getWritableStreamState() does !stdout.destroyed && !stdout.writableEnded && (stdout.writable ?? true), which is good evidence the narrow surface is deliberate.
  • on()/off() are only used when rendering interactively (ink.tsx:457) and by useWindowSize() (use-window-size.ts:32).
  • Ink touches stdin only when stdin.isTTY is set (App.tsx:209). Raw mode input then needs addListener(), read(), setRawMode(), setEncoding(), ref() and unref(), and kitty keyboard detection also uses on(), removeListener() and unshift() (ink.tsx:1202-1255).
  • If a stream reports writableLength, Ink passes a completion callback to write() (ink.tsx:896) and waits for it, so such a stream must invoke it or waitUntilExit() and waitUntilRenderFlush() never settle.

That's the contract, and it's what the new types describe — including the conditional parts, in their doc comments, so nobody has to rediscover this from a crash.

The proposal

export type InkOutputStream = {
	columns?: number;
	rows?: number;
	isTTY?: boolean;
	destroyed?: boolean;
	writable?: boolean;
	writableEnded?: boolean;
	writableLength?: number;
	write(data: string, ...rest: unknown[]): unknown;
	on?(event: unknown, listener: unknown): unknown;
	off?(event: unknown, listener: unknown): unknown;
};

export type InkInputStream = {
	isTTY?: boolean;
	on?(event: unknown, listener: unknown): unknown;
	read?(...args: unknown[]): unknown;
	setRawMode?(mode: boolean): unknown;
	setEncoding?(...args: unknown[]): unknown;
	unshift?(...args: unknown[]): unknown;
	addListener?(event: unknown, listener: unknown): unknown;
	removeListener?(event: unknown, listener: unknown): unknown;
	ref?(): unknown;
	unref?(): unknown;
};

export type RenderOptions<
	OutputStream extends InkOutputStream = NodeJS.WriteStream,
	InputStream extends InkInputStream = NodeJS.ReadStream,
> = {
	stdout?: OutputStream;
	stdin?: InputStream;
	stderr?: OutputStream;
	// …everything else unchanged
};

const render = (
	node: ReactNode,
	options?: Writable | RenderOptions<InkOutputStream, InkInputStream>,
): Instance => {};

Which makes the original snippet compile as-is:

render(<App />, {stdout: sink, debug: true});     // 
const mySink: InkOutputStream = sink;             // ✓ exported, so you can type your own

Decisions I made, and where I'd like your opinion

Why structural types rather than just loosening to stream.Writable. That would fix PassThrough, but not the case that motivates this: an in-memory sink is usually a plain object, not a Writable subclass, and forcing people to extend Writable for a write() method is a worse deal than the assertion. Structural types accept both.

Why RenderOptions is generic instead of just narrowing the three properties. Narrowing them directly was my first attempt, and it broke code that reads the options back out:

const stream: NodeJS.WriteStream = options.stdout!;   // used to compile
options.stdout!.cursorTo(0);                          // used to compile

TypeScript uses one type for reads and writes of a property, so widening what callers may pass in necessarily narrows what everyone else gets out. Generic parameters with Node's types as the defaults avoid that: bare RenderOptions means exactly what it means today, render() accepts the wide instantiation, and wrapper authors can ask for RenderOptions<InkOutputStream, InkInputStream> explicitly. The cost is two type parameters on a public type. I think that's the right trade, but it's your API — happy to switch to the plain narrowing if you'd rather have the simpler type and accept the read-side break.

Why stream detection is left exactly as it is. An earlier revision of this PR also let you pass a capture stream as the positional second argument, render(tree, sink). That required changing stream detection from options instanceof Stream to a duck-typed typeof options.write === 'function', and I convinced myself it was strictly wider. It wasn't:

  • render(tree, null) and render(tree, 42) started throwing TypeError: Cannot use 'in' operator…, where master falls back to the defaults and renders. Any JS caller forwarding a possibly-null options value would have hit that.
  • An object carrying a write method but intended as options got reclassified as the output stream, silently dropping the options it carried.

I don't like changing behavior people already depend on just because it's convenient, and a second way to pass a stream doesn't come close to earning it — the upside is a shorter call for a handful of users, the downside lands on people who never asked for it. So detection is byte-for-byte master's instanceof Stream, and the positional parameter is typed Writable: the streams that check accepts and Ink can actually write to. It's deliberately a little narrower than the check — a Readable or a bare Stream subclass is rejected at compile time even though instanceof Stream would take it — but nothing the type allows is mishandled at runtime, which is the direction that matters. Capture streams go through the options object, which is the documented form and what ink-testing-library uses. I verified behavior matches master for null, numbers, strings, functions and options-shaped objects carrying write.

Why every InkInputStream member is optional. My first version required on and read. That looked reasonable until the new test had to stub both with no-ops for methods Ink never calls on a non-TTY stdin — the split matched nothing at runtime. Ink touches stdin only when isTTY is set, so the honest encoding is all-optional, with the input requirements documented on the type.

This has a cost I want to flag rather than bury: because Node types tty.ReadStream and tty.WriteStream as sockets, an all-optional input type also accepts an output stream, so render(tree, {stdin: process.stdout}) compiles now where master rejected it. It would fail at runtime if a component called useInput. The only member that discriminates the two is setRawMode, and requiring it would rule out the minimal stdin that motivated the change. I chose the weaker type and documented the contract, but this one is genuinely a judgement call and I'll follow your preference.

What I deliberately left alone. Ink's internals still use Node's stream types. render() casts at that single boundary, so ink.tsx, the contexts, the hooks and instances.ts are untouched, and useStdout()/useStdin()/useStderr() keep their current return types. Migrating those is a bigger, breaking change and doesn't belong here — but say the word and I'll do it in this PR if you'd prefer they match.

One intentional behavior difference. Passing a stream as an explicit undefined or null now falls back to the process stream instead of overriding the default with it. On master the nullish value reached the renderer, and none of these worked:

render(<App />, {stdout: undefined});  // master: TypeError: Cannot read properties of undefined (reading 'isTTY')
render(<App />, {stdout: null});       // master: TypeError: Cannot read properties of null (reading 'isTTY')
render(<App />, {stdin: undefined});   // master: renders nothing, silently  the TypeError is swallowed by the error boundary

All three render here. Turning two crashes and a silent no-op into working renders seemed worth keeping, but it is still a change, so it's your call — I'll restore exact parity if you'd rather have it. Everything else is untouched, down to reading only own enumerable properties off the options object, so an inherited stdout getter is ignored exactly as it is today.

Compatibility

Every pattern below typechecked against both master and this branch:

Pattern master This PR
render(tree)
render(tree, {stdout: process.stdout, stdin: process.stdin, stderr: process.stderr})
render(tree, process.stdout)
render(tree, {stdout: sink as unknown as NodeJS.WriteStream})
Forwarding a RenderOptions value into render()
options.stdout!.cursorTo(0)
const stream: NodeJS.WriteStream = options.stdout!
render(tree, {stdout: new PassThrough()})
render(tree, new PassThrough())
render(tree, {stdout: captureStream, stderr: captureStream})
render(tree, {stdout: captureStream, stdin: {}})
RenderOptions<InkOutputStream, InkInputStream> for wrapper authors

Nothing that compiles today stops compiling, and the runtime is unchanged.

One implementation detail worth flagging: the methods use method shorthand rather than arrow properties, so their parameters stay bivariant and Node's stricter signatures (setEncoding(encoding: BufferEncoding), for one) still satisfy them. As arrow properties this would reject process.stdout under strictFunctionTypes.

Six options as any casts in test/components.tsx become unnecessary with these types and are removed, since @typescript-eslint/no-unnecessary-type-assertion fails the lint step otherwise. That's the only reason this PR touches that file.

Docs and tests

The options section of render(tree, options?) now names the new types, says the streams don't have to be terminal streams, points at the conditional on()/off() and raw-mode requirements, and tells wrapper authors to annotate with RenderOptions<InkOutputStream, InkInputStream>.

New tests in test/render.tsx:

  • process.stdout/stdin/stderr still assign to RenderOptions (compile-time).
  • A {columns, rows, write} object plus a bare {} stdin render and receive output through the options form, with no assertions anywhere in the test.
  • The same capture stream with debug: true — the motivating case — asserting the output arrives before unmount, which debug mode writes synchronously during render().
  • An options object carrying a write method still being treated as options, pinning the classification that the duck-typed revision above got wrong.
  • A PassThrough passed positionally, covering the instanceof Stream path. Nothing in the suite called render() with a positional stream before this PR.

npm test -- --serial, the command CI runs, exits 0 on both Node 24 and Node 22: typecheck, lint, then 1041 passing, 4 known failures, 1 todo.

@tomaspiaggio
tomaspiaggio force-pushed the fix/render-options-stream-types branch 6 times, most recently from e767c28 to 329f9c3 Compare July 30, 2026 01:14
@tomaspiaggio
tomaspiaggio force-pushed the fix/render-options-stream-types branch from 329f9c3 to ef77e09 Compare July 30, 2026 02:00
@tomaspiaggio

Copy link
Copy Markdown
Author

@sindresorhus sorry for not opening an issue before doing this. I had this patched on my project and thought would be a nice inclusion. I cleaned it up a bit but i think it's good to go.

Please let me know if you need any changes. It's NOT urgent. Just types ergonomics mostly.

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