Use `Core.stdout` and `Core.stderr` per default

When using the --trim functionality, it quickly becomes inconvenient to always write the io to be Core.stdout when the non-trimmable Base.stdout was so convient as the default parameter (I focus here on stdout, but the same goes for stderr).

So how can we use Core.stdout as default in programs which will be trimmed?

We can make use of world age and overwrite the corresponding functions from within our app module:

module MyApp
Base.print(x) = Base.print(Core.stdout, x)
Base.display(x) = Base.display(Core.stdout |> TextDisplay, x)
[…]

From now on we can write

    print(5)
    display(5)

while still being trimmable. This is nice, but only a fraction of the methods which need to be overwritten. Ah, and this leads to a direct dependency on a specific Julia patch version and to a lot of test cases you should implement, because this is worse than type piracy. How do you call this? Type terrorism?

Is there a more complete way to achieve this? The root cause is that Base.stdout needs to get the “correct” type (i.e. what it actually has and not the abstract IO).

  • Could we somehow list the Union of types which Base.stdout actually can have and tell Julia to union-split/world-split until this Union is covered?
  • Can we somehow change the type of Base.stdout to Core.CoreSTDOUT?
  • Can we somehow alias Base.stdout to point to Core.stdout?
  • Can we introduce a command line flag to Julia to implement something like this?
module MyApp
Core.eval(Base, :(stdout = Core.stdout))
[…]

does not work (“Evaluation into the closed module Base breaks incremental compilation because the side effects will not be permanent. This is likely due to some other module mutating Base with eval during precompilation - don’t do this”). Julia is certainly right with “don’t do this”, but for trimmed compilation a problem with precompilation seems to be acceptable and this would be convenient.

Any ideas how to get trimmability and convenience?

Coming back to this after some discussion with Claude which I kind of forced my ideas into, where you can see the summary from (written by Claude Opus 4.8, edited by me)

The actual root cause

Base.stdout is declared stdout::IO — an abstract binding type. The concrete type isn’t known until __init__, where init_stdio probes fd 1 and returns one of a small closed set: IOStream (file), TTY (terminal), TCPSocket, or PipeEndpoint (pipe), optionally wrapped in IOContext when color is on. Because the declared type is the open ::IO, the trimmer must assume “anything <: IO” and bails as the type is unbounded.

Why Core.stdout works but is not perfect

Core.stdout/CoreSTDOUT is a single fixed type whose tty/file/pipe distinction is pushed into C (jl_uv_puts), so it devirtualizes perfectly and handles pipes fine. But as noted upthread, it means reworking call sites and you lose IOContext features (:color, :displaysize, …). It’s the “collapse to one type” extreme: smallest binary, biggest rework.

The alternative: just narrow the binding type under trim

You don’t need to collapse the hierarchy, and you don’t even need const. A typed global enforces its declared type on every assignment, so if under --trim the binding is declared as the closed union it can actually hold:

stdout::Union{IOStream, TTY, TCPSocket, PipeEndpoint,
              IOContext{IOStream}, IOContext{TTY},
              IOContext{TCPSocket}, IOContext{PipeEndpoint}}

then inference sees a finite concrete set at every print site and union-splits into a bounded switch. Advantages over const:

  • redirect_stdout keeps working, as long as the target is in the union (file/pipe/tty/socket — the realistic cases). Only redirect-to-arbitrary-IO (e.g. an IOBuffer) would now throw instead of silently widening.
  • It’s a localized change (narrow the binding + give init_stdio a concrete-union return type), not an IO redesign.

Cost: a constant-factor binary increase, since the write/show stack is retained for ~4 backends instead of 1. The value-type axis stays finite too (your printed types + their transitive show closure), which for a typical CLI is modest.

On the IOContext half

Note that IOContext unqualified is IOContext{T} where T (infinite leaves), so it has to be expanded to the realistic non-nested wraps as above. And whether IOContext is present at all is currently a runtime decision — _reinit_stdio wraps only when --color is set and fd 1 isn’t a TTY. But that decision is central and startup-fixed, not per-call. So the juliac build (which already overrides reinit_stdio) can pin the color policy at build time; commit to “no color” and the IOContext members drop out entirely, halving the union. Which concrete backend you get is an unavoidable runtime probe; whether IOContext is in the union is a build-time policy you control.

TL;DR

A possible fix is narrowing the declared binding type of stdout/stderr to their closed concrete union under --trim. That makes the default sink statically union-splittable, keeps fd/file/pipe redirection working, and costs only a bounded constant-factor in binary size. Pinning the color policy in the trim build removes the IOContext half on top of that.

Base.stdout::Core.CoreSTDOUT vs. the narrowed-union proposal

These are really the same family — “make the declared binding type concrete enough for the trimmer” — but at opposite ends. The key realization is that stdout::CoreSTDOUT is just the Core.stdout outcome reached by retyping the binding instead of rewriting call sites. Since user code already reads the global stdout, retyping the binding is strictly nicer than the original Core.stdout suggestion: you get the single-concrete-type benefit with zero call-site changes. So it dominates the plain Core.stdout proposal.

The interesting comparison is therefore stdout::CoreSTDOUT (collapse to one type) vs. the narrowed union (keep the hierarchy, just bound it).

stdout::CoreSTDOUT Narrowed union
Declared type Core.CoreSTDOUT (one concrete singleton) Union{IOStream,TTY,TCPSocket,PipeEndpoint,IOContext{…}×4}
Call-site rework None None
Binary size Smallest — 1 backend column, no switch Constant-factor larger — ~4 backend columns, union-split
IOContext features (:color, :displaysize, :limit) Lost — singleton carries none Kept
redirect_stdout Impossible — only the singleton inhabits the type Works for in-union targets (file/pipe/tty/socket)
_reinit_stdio under trim Trivial — no fd probe, binding stays the boot singleton Must run the probe and store a concrete-union result
Runtime behavior vs. today Changed — degrades to raw byte sink Preserved — same concrete types flow as now

How to read this

stdout::CoreSTDOUT is the floor: the smallest possible binary and the simplest implementation (you don’t even need the startup probe — stdout just stays Core.stdout). The price is that it isn’t behavior-preserving: no color, no displaysize-aware show, no IOContext, and redirect_stdout can’t work at the Julia level because the binding admits exactly one value. Redirection would have to happen at the fd/C layer before launch.

The narrowed union is a superset of fidelity at a bounded size cost. It keeps real TTY/IOContext behavior and keeps Julia-level redirection working, because the binding still admits the full realistic set of backends — it’s just closed instead of open ::IO. You pay for that with a write/show stack retained for ~4 backends instead of 1, plus a union-split branch at each print site.

One important caveat for stdout::CoreSTDOUT: it can only ever be a trim-only retyping. Applying it to normal Julia would regress the interactive experience (REPL color, sizing, rich display all key off the TTY/IOContext types). The narrowed union is also trim-only, but it’s behavior-preserving even where it applies, so it’s far less disruptive if the policy ever leaks beyond trim.

Bottom line

  • Want the absolute smallest static binary and can live without color/redirection/rich display → stdout::CoreSTDOUT. It’s the cleanest spelling of the Core.stdout idea and needs no call-site edits.
  • Want the binary small but still behaving like Julia (color, displaysize, working redirect_stdout) → the narrowed union, optionally dropping the IOContext half by pinning the color policy in the juliac build to shrink toward the CoreSTDOUT floor.

They’re two points on one dial: CoreSTDOUT is “one column,” the narrowed union is “a handful of columns,” and the build-time color policy is how you slide between them.