Your Interface Has Two Channels

tomeraberbach1 pts0 comments

Your Interface Has Two ChannelsThis code would easily pass a cursory review:<br>const response = await fetch('https://example.com/flags.json')<br>const flags = await response.json()<br>startServer(flags)Then one day the endpoint returns a 500, flags becomes { error: 'Internal Server Error' }, no key matches a real option, and the server silently starts with every default.<br>fetch doesn’t reject on HTTP errors. It resolves either way, and nothing in the interface tells you to check response.ok. The bug isn’t that you decided to skip error handling. You never realized there was a decision.<br>Everyone has used an interface that threw them into the Pit of Despair like this. I’ve hit the bottom enough times to notice the pattern.<br>For each concern an interface exposes, it either forces you to confront it or allows you to inadvertently ignore it. Ignoring a confronted concern is an intentional decision, but ignoring an unknown one commits you to assumptions you didn’t know you made. That signaling determines how the interface fails: by decision or by accident.<br>Once you see interfaces this way, many familiar design questions become the same. Throw or return an error value? Required parameter or default? Object or union type? Each asks how loudly the interface should signal a concern. Soon you’ll have principles for answering.<br>Concern signaling<br>I’m borrowing the signaling terminology from telecommunications.<br>In-band signaling means control information travels in the same channel as data. Out-of-band signaling uses a separate channel for control information.<br>The distinction maps cleanly onto interface concerns. Every interface has the same two channels, and each of its concerns travels on one of them: the channel the user must confront to use the interface at all, or the channel off to the side that they can miss.<br>Error handling<br>Consider a function that returns a union of success and failure. The caller cannot use the function without being aware of the possibility of an error.<br>For example, returning Rust’s Result type forces the caller to explicitly handle the error:<br>fn parse_config(raw: &str) -> ResultConfig, ParseError> { ... }

// Trying to use the result without unwrapping would trigger a type error.<br>// If the caller decides to ignore the error, then it's intentional.<br>let result = parse_config(raw);<br>match result {<br>Ok(config) => start_server(config),<br>Err(e) => eprintln!("{e}"),<br>}In this case the error is in-band. Confronting it is inseparable from using the interface.1<br>Now consider a function that returns Config and throws on failure. The caller can use the Config directly because the exception requires no acknowledgment.<br>For example, throwing JavaScript’s Error allows the caller to proceed without confronting the error:<br>/** @throws Error for invalid configs. */<br>function parseConfig(raw: string): Config {<br>// ...

// The caller may inadvertently ignore the error if they did not read the<br>// function documentation and are unaware it can throw.<br>const config = parseConfig(raw)<br>startServer(config)In this case the error is out-of-band. Confronting it requires discipline, and a caller can slip past it without knowing it exists.<br>A function that throws a checked exception moves the error back in-band. The caller is forced to explicitly catch or propagate the error.<br>For example, Java’s throws keyword makes error handling in-band:<br>Config parseConfig(String raw) throws ParseException {<br>// ...

// The caller is forced to handle the checked exception. This won't compile<br>// without either catching or declaring `throws ParseException`.<br>void start(String raw) throws ParseException {<br>Config config = parseConfig(raw);<br>startServer(config);<br>}However, a concern that’s more in-band than it deserves backfires because acknowledgment becomes a reflex. Java programmers infamously silence checked exceptions using empty catch blocks, unchecked rethrows, or throws Exception clauses.<br>That’s worse than out-of-band. The code only looks like it confronted the concern.<br>Rust’s success suggests Java’s failure was ergonomics, not confrontation. Result forces the same acknowledgment, but it’s pleasant to handle or propagate.<br>Checked exceptions also reveal that the channel carrying the data is not the same as the channel that carries the concern. A checked exception travels outside the return value, yet the concern is in-band. The inverse mismatch exists too: in-band data does not imply in-band concerns.<br>For example, C-style -1 sentinel values are in-band data-wise, but out-of-band concern-wise because the user could overlook checking for the sentinel:<br>// `open` signals failure via a -1 return value. The caller may not check for -1<br>// if they're unaware it's a possible return value.<br>int fd = open("config.json", O_RDONLY);<br>// Undefined behavior if `fd == -1`.<br>read(fd, buf, sizeof(buf));Naming<br>Names can move concerns in-band or out-of-band.<br>For example, Java’s HashSet has no guaranteed iteration order, but the name only describes the implementation, not the ordering...

error band config interface caller concern

Related Articles