We're at at an interesting phase of development at Codecannon. The product is about to go live, and the patterns we used will be put under scrutiny. Codecannon allows you to use a UI/AI builder to define an application specification. We use your specification to deterministically generate a full-stack app codebase without AI.
As with all products that grow out of your needs, the needs you might have may differ from the needs of your end users. Habits, standards, use-cases and requirements therefor are a very subjective topic.
Before we release Codecannon there are tons of minor gripes we have with different patterns and functionalities inside the tool. It's only natural when you spend so much time building something. Some of these come from ideas that have not been fully battle tested, others come from potential use-cases that we can foresee. But can't be sure these are use-cases others have.
The cognitive dissonance begins
There is a pattern matching that description, that is currently gnawing at our brains. Result objects in TypeScript.
For the uninitiated, Results
are a special type that functions can return (popularized in recent time,
mostly by Rust). As Rust describes Results:
Result<T, E>
is the type used for returning and propagating errors. It is an enum with the variants,Ok(T)
, representing success and containing a value, andErr(E)
, representing error and containing an error value.
So in plain speak, Results
are used in functions, that can in some way, shape, of form, return an error
state. In TypeScript land this is known as throwing.
The difference comes from how different languages handle errors.
In JavaScript or Typescript (or indeed, most popular languages), errors inside functions are "thrown". That means that when an error occurs, the function throws a fit of rage, and throws the error somewhere into the ether. Unsuspecting functions who dared to call (upon) this juvenile function, can then handle the offender by using a try/catch block, carefully prompting the unstable function and staying alert, so as to catch the flying errors, preventing them from hitting an unsuspecting neighbor.
This can be very exhausting for the developers, usually overseeing a massive kindergarten of functions, as they need to keep track of which functions might exhibit this unstable behavior. They must warn other functions to be ready for imminent breakdowns that they might experience when interfacing with their kind.
Then come the big boy languages like Rust or Go.
These languages are hosts to a sophisticated breed. They create functions that clearly communicate
their intent and needs through type annotations. Whenever you call these functions and engage them
in an exchange of goods (inputs and outputs), they will set clear boundaries and tell you exactly
how they might react. They inform you of their limited abilities, and notify you that sometimes,
things go wrong in X specific ways. These are truly cultured systems. The functions don't simply
"throw" errors when they encounter or cause them, they "pass" the errors back to you in a neat package.
They inform you if something went wrong, and how, politely. In Rust, they always return Result
enums,
in Go, they'll simply give you two values, one for the data, and one for the error, if there is one.
You can check the bag for poop yourself and decide if you still find the content appetizing.
What are you even talking about?
Simply put this is what happens in TypeScript:
// === Scenario: Careful dev with coffee ===
const rawGood = '{"name":"Alice","age":30}';
interface User { name: string; age: number; }
try {
const user = JSON.parse(rawGood) as User;
console.log("☕️ Parsed user safely:", user);
} catch (e) {
console.error("☕️ Whoops, parsing failed:", e.message);
}
// === Scenario: “One too many beers” dev skips error handling ===
const rawBad = '{name:"Bob",age:25'; // malformed JSON
// Oops—no try/catch! This will throw and crash at runtime.
const sloppyUser = JSON.parse(rawBad) as User;
console.log("🍺 Parsed user (or did we?):", sloppyUser);
// Notice how the programmer couldn't be drunk to code this language
And this is what happens in Rust:
// Rust – returns a Result enum
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct User {
name: String,
age: u8,
}
fn parse_json<T: DeserializeOwned>(input: &str) -> Result<T, serde_json::Error> {
serde_json::from_str(input)
}
fn main() {
let raw = r#"{"name":"Alice","age":30}"#;
match parse_json::<User>(raw) {
Ok(user) => println!("Parsed user: {:?}", user),
Err(e) => eprintln!("Failed to parse JSON: {}", e),
}
}
// Not shown here, but even though the developer might successfuly sneak into the
// bedroom and not get yelled at by their spouse after a night of drinking -
// they won't be as lucky with the Rust compiler.
//
// The compiler will be angry if they don't handle the errors, or at least state,
// in writing, that they don't care to handle them.
Or Go:
// Go – returns a value and an error
package main
import (
"encoding/json"
"fmt"
"log"
)
type User struct {
Name string `json:"name"`
Age int `json:"age"`
}
func parseJSON(data []byte) (User, error) {
var u User
err := json.Unmarshal(data, &u)
return u, err
}
func main() {
raw := []byte(`{"name":"Alice","age":30}`)
user, err := parseJSON(raw)
if err != nil {
log.Fatalf("Failed to parse JSON: %v", err)
}
fmt.Printf("Parsed user: %+v\n", user)
}
// In Go, not handling errors or at least writing the `if err != nil` statement
// is a compiler error.
//
// Go, the language that helps you drink and code
Regardless of how you might feel about different syntax decisions or approaches to handling errors. We think it would be hard to make an argument that at least knowing that a function might error, and the type of error it might return, is in any way worse, than not having that information.
Our crisis within
So herein comes our dilemma.
Is this a pattern TypeScript, and more importantly, TypeScript developers are ready for?
There are a few concerns we have with including this pattern in our frameworks, libraries or other generated code:
- We will not get the same level of support for these patterns in TypeScript as we would in errors-as-values languages no matter how hard we try. Even if we type annotate the entire standard library, there will still be dependency packages that don't implement this pattern. Even if we can get this pattern working in our projects, there will be uncovered cases with external code. And we can ignore the fact that in the end, everything becomes JavaScript, which doesn't offer even the most basic of TypeScript guarantees.
- There are multiple ways to implement errors-as-values in TypeScript and there is no standard to refer to. This is evident by tens of packages and hundreds of tweets that provide different errors-as-values solutions, which are mostly incompatible between each other. Feels like the unofficial (commonjs, amd, umd, etc.) module systems fiasco we just barely escaped not too long ago.
- TypeScript developers did not choose and errors-as-values language. Granted, it's possible that many TypeScript developers didn't choose TypeScript at all (they just felt it wasn't as bad as JavaScript and wanted to build web stuff), but still, we'd guess there's a larger portion of the TS developer community that would be against errors-as-values patterns, than in errors-as-values languages. If for no other reason, because they work in a language where this is not the standard, harder to implement and enforce, and difficult to refactor for.
So we find ourselves in a pickle.
Is this a pattern we want to introduce in our product and the code we deliver, or is it something we should avoid?
What does this mean for us?
We'd like to tell you we're leaning into a direction, but that would be a lie. Instead, we're stuck writing a blog article to put our thoughts on paper, and just delaying the inevitable return of the ticket to our backlog marked as "We need to think about this for a bit first".
We would love to try errors as values, as we like the errors-as-values approach far more as throwing. But we don't know if that's the correct call for the code we'll ship to customers.
We can't say we've battle tested any of the TS specific approaches, but we can confidently draw on our experience and state that any of them would be better suited to our taste than try/catch, and they wouldn't work any worse that what we have now.
But as they say, "The customer is always right". So we'll defer our judgment until we've had the chance to gather more feedback on this.
Who knows, maybe we add an option so users can choose which pattern they want in their generated apps in the future.
We're not afraid to make opinionated decisions and follow a coherent design philosophy when developing our products, but there's nothing about our philosophy or core values forcing us to jump head first into a trendy idea or implementation of a cool feature.
We don't want to create more complexity in the software engineering world or even worse, add brittle dependencies that our customers might need to maintain. Especially when the magnitude of the benefit is questionable at best.
So for all the errors-as-values and err != nil
haters out there... you win for today. Hopefully
not for long though.
Personally we belong to the errors-as-values camp. But our products are made to serve you, so we'll defer to the decisions the language designers made, when they built the world we live in today.
And for all the TS haters out there yelling at us that we should support errors as values and try/catch sucks. We agree. But we're not in the position to solve your problem.
What do we do about it then?
As we see it today, this is a problem that must be tackled on a different level of the web development ecosystem. Maybe there's a typescript compiler feature that will enable us to write all promises as errors/values. Or maybe we can form a movement to get the Try Operator proposal pushed forward on TC39's side (this still doesn't solve our type problem, but it's a step in the right direction).
Whatever the case may be, we'd prefer to not be scared into implementing a solution that 90% of devs will still attack us for, for not being the right one.
So all in all, after committing our thoughts to paper, we're no closer to making a decision, except for the decision, not to make a decision right now.
We hope you understand the position we are in, and we hope to hear any feedback you might be willing to offer. Whether you pass it along nicely, or throw it like a web native. We're here to catch and handle anything.