Maintaining quality while your teams scale
Earlier, I stayed away from having to learn yet another thing to do my job. Javascript with a sprinkling of Typescript felt sufficient. It can help newcomers understand the codebase with self-documenting code. You can also circumvent some landmines as the JS language evolves. Additionally, you can skip the overhead of compiling, write good JS, and hopefully that would get you pretty far.
While this is true, I have also learned that you run into tricky problems when teams scale. This is less of a technical issue and more of a process issue that can be solved through technology. At my job, we went from a handful of engineers to 60 engineers within a span of a few years. Watching the codebase evolve as people with different skill levels started contributing code was a very unique experience.
Sure you can control for quality when the team is small but eventually, there is a breaking point where PRs start accumulating (or going stale) and several projects run in parallel and it becomes difficult maintain a hold on architectural patterns. Eager contributors focus on getting stuff done and anyone that tries to add some method to the madness unfortunately, becomes the bottleneck, to say the least. If all that action happens within the same monolith, you can imagine how chaotic that would be.
Constraint at the language level
This was my biggest motivation to approach compile to JS languages. I want the codebase to scale along with the team and not have to deal with the variety of issues that come with scaling a team quickly – which is a desirable thing. I want certain things to be enforced at the language level and not have to gate keep the codebase to make sure everyone is sticking to the same pattern. I want only one way to do things in the language.
That constraint sounds harsh, but, I have spoken to folks who have followed that model and swear by it. If you have the luxury of knowing your team is going to scale, it's better to apply those constraints before you need them. As time evolves, your codebase still looks like a single person wrote it and doesn't mix approaches.
Type system + Pattern Matching
Speaking of constraints at the language level, here's what I have understood to be the big difference in compile-to-JS languages. I found this in the Rescript docs and it made complete sense. What is the big gap in JS that all these languages are trying to sort out? How does a type system magically enable you to program with "no runtime errors"?
The answer is a lot simpler than you would think and I think it's worth quoting this paragraph in its entirety.
Philosophically speaking, a problem is composed of many possible branches/conditions. Mishandling these conditions is the majority of what we call bugs. A type system doesn't magically eliminate bugs; it points out the unhandled conditions and asks you to cover them. The ability to model "this or that" correctly is crucial.
The variant type, also known as algebraic data type allows you to model the different states your data can be in. Some of the newer languages allow you to code this in an expressive manner. This expressiveness, combined with good pattern matching syntax, makes it a lot easier for the compiler to detect if you are not addressing all cases that you have modeled your data in or not. Thereby allowing the compiler to detect bugs before you ship them to the user.
This also means, if you forgot to model your data appropriately you'll still have bugs.
Comparing Elm and JS
Here is an example from apollo docs for fetching data:
const { loading, error, data } = useQuery(GET_DOGS);
if (loading) return 'Loading...';
if (error) return `Error! ${error.message}`;
return (
<select name="dog" onChange={onDogSelected}>
{data.dogs.map((dog) => (
<option key={dog.id} value={dog.breed}>
{dog.breed}
</option>
))}
</select>
);
This library clearly models the different states in which your query could be in: loading
, error
or data
. However, there is nothing at the language level preventing you from skipping the first two if conditions. You could just as well write the following and only handle the happy path - nothing that prevents you from coding like this.
const { loading, error, data } = useQuery(GET_DOGS);
if ((data?.dogs?.length ?? 0) > 0) {
return (
<select name="dog" onChange={onDogSelected}>
{data.dogs.map((dog) => (
<option key={dog.id} value={dog.breed}>
{dog.breed}
</option>
))}
</select>
);
} else {
return null;
}
Here's a similar example in Elm. You start by modeling the different states your data can be in:
type Model
= Failure
| Loading
| Success String
From that point on, whenever you are trying to work with this model, you have first check the type. This is where pattern matching comes into play. See below view function for the model above.
view : Model -> Html Msg
view model =
case model of
Failure ->
text "I was unable to load your book."
Loading ->
text "Loading..."
Success fullText ->
pre [] [ text fullText ]
The difference between the JS example and this Elm example is that if you skip the loading or failure states, you get the following error.
This `case` does not have branches for all possibilities:
80|> case model of
81|>
82|> Success fullText ->
83|> pre [] [ text fullText ]
Missing possibilities include:
Failure
Loading
I would have to crash if I saw one of those. Add branches for them!
Hint: If you want to write the code for each branch later, use `Debug.todo` as a
placeholder. Read <https://elm-lang.org/0.19.1/missing-patterns> for more
guidance on this workflow.
The type system enables you to model your data states accurately without needing to rely on null
. The pattern matching syntax allows you to branch out cleanly without having to write brittle if conditions. The compiler can look into your type definitions and report back fi you have failed to account for one of the possible cases. This is how compile to JS languages allow you to code without any runtime errors.
In theory, you could achieve the same effect with Javascript but it would involve a lot more enforcement (via tooling or manual gatekeeping) to ensure everyone follows the same pattern. Alternatively, when the code wouldn't compile if you don't address all cases, you eliminate a whole class of bugs. This was the promise of Typescript but the more I look into it, the more I believe having stricter constraints at the language level is better for teams that are scaling rapidly.