Are you using types when you should be linting?
I’m a big fan of static typing. With Kanel, I generate types for Typescript from Postgres databases. That means that the compiler helps me remember if a member
has a fullName
or firstName
+ lastName
columns/properties. If I mistype or mis-remember, a squiggly line in my editor or a CI failure stops me long before any user experiences an error. In a large system with many components, type checking saves me countless hours of work.
Using types to remember and enforce the shape of your data model is extremely valuable.
It doesn’t stop there, though. Type systems can be used to enforce a meta-architecture of sorts, which in many ways is even more powerful. Languages like Rust and Haskell excel at this. A common phrase among Haskellites is “when it compiles, it works”.
Let’s look at a trivially simple example. The most famous design pattern from the nineties was the mighty singleton. I don’t personally see it much these days but it probably sneaks in many places still. The idea is that there are some things you want one and only one instance of, like the connection to the database. And while you are perfectly capable of remembering this, your experience tells you that the developers you work with simply cannot be trusted not to create new instances, the little rascals, so you can nudge them in the right direction with this pattern.
What happens when you try to instantiate a singletonized class:
Ah-HA! The type system saves the day, and your junior developer will realize that they are doing something they are not supposed to. The error message isn’t too helpful — it’s sort of like your mechanic telling you that there is insufficient electrical current flow between the car’s alternator, voltage regulator and starter motor, when all you need to know is that your battery is dead. But, with the singleton being such a common pattern, many developers will intuitively look for a static getInstance
method or similar.
What we’re really doing with our complicated types is building a fixture for building our app. Or, if we’re making a library, it might be at an even higher level of abstraction. With higher-kinded types we might be enabling creating types for creating a certain architecture. Let me be completely clear that I am not arguing against typing, I just think that that it can and should be complemented with linting. In fact, I think that any project of significant size, private or public, should come with a set of custom linter rules.
The React Rules of Hooks is a nice example. The linter rules are extremely helpful when using hooks, and I cannot imagine the kind of trickery they would have had to pull if they wanted to achieve the same using types.
I am working on a library that synchronizes state from the database to the frontend in endpoints. Basically, if any mutation is made, the response should contain an array that describes those changes. For simple CRUD operations, this can be done automatically. But for instance, when a table has a trigger attached, there can be undetected updates which require “manual” updates. That is quite easy to forget, so I wanted to try and create a guard rail for this.
My first intuition was to use the type system. Maybe such mutations could return a state
value that was marked as unresolved, and then a function that creates the necessary updates would take this state value and mark it as resolved. Endpoints would then have to return such a state value, if it was unresolved, it would trigger a compiler error. In Rust, I think this could be done quite elegantly because of its sophisticated ownership tracking. But I failed to come up with a nice solution in Typescript.
Instead, I wrote an ESLint rule. This is what the first version looked like, with some helper functions omitted:
module.exports = {
create: function (context) {
return {
CallExpression(node) {
if (isCallExpression(node, "router", ["get", "post", "put", "patch", "delete"])) {
if (node.arguments.length !== 2 || node.arguments[1].type !== "ArrowFunctionExpression") {
return;
}
const knexCalls = countMethodCalls(node.arguments[1], "db", "knex");
const resolveCalls = countMethodCalls(node.arguments[1], "db", "resolve");
if (knexCalls !== resolveCalls) {
context.report({
node,
message: `There must be a corresponding db.resolve call for each db.knex call.`,
});
}
}
},
};
},
};
Basically, it checks that any db.knex
call in an endpoint is complemented by a db.resolve
call by making sure they are called the same number of times. I have since changed it quite significantly, but I wanted to show this version because it’s so trivial. If you are refraining from writing linter rules because it seems daunting, consider giving it a try. (Pro tip: ChatGPT or Co-Pilot are great at it!).
While this rule does not cover everything, it has the very nice feature that the error message tells you what the solution is. If this had been a type error, you would almost certainly be facing a cryptic message that only makes sense if you already know what’s wrong. With this solution, our architecture is both more self-validating and self-documenting. It sure beats looking in that eternally outdated wiki that we all edited after the big planning meeting a year and a half ago!
But, you might say, this doesn’t sound very sound! Indeed, it‘s not. And I know that this is off-putting to some more than others. I personally don’t mind the lacking soundness in the Typescript type system, but to some it’s a complete deal-breaker. To me, the beauty is that linter rules are easy to disable in special conditions, so you really only do need to get them 80% right for them to be useful.
In my mind, the triad of typing, testing and linting should be the gold standard of writing solid code.