39 comments

[ 2.7 ms ] story [ 63.7 ms ] thread
Just read this article and it vibes with my personal journey. I've built and maintained many web applications for more than 20 years. PHP, Ruby, Python, Java, Dotnet, Go, Node, Bun etc.

The most maintainable codebase is the one that requires the least guessing from the developers perspective. Where everyone that looks at the code immediately gets a good understanding of what is going on. When it comes to this I personally had the best experience with Go so far as it is verbose when it comes to error handling (I don't need to guess what happens or what can go wrong etc.). Is it the most elegant? No, but I don't need to build a big context window in my brain to understand what is going on immediately (looking at you Rails callbacks).

Anyways I found the article interesting and as I am also using SQLC I think it is something that goes in that direction.

(comment deleted)
I agree, but I also thing that static analysis is a requirement for high quality. The obvious solution is (global) type inference, but I have yet to see a language that embraces it.
Do you really want type inference to be global? The idea that changing one line in a function might change the type of a whole other function — and thus variables assigned to its result, which affect the types of other functions, which ... — well it just seems like a lot to have to reason about.
There are practical limits on global type inference; eg, sometimes writing Haskell youll be not sure what’s going on with some code that won’t typecheck, and the way to deal is break it down piece be piece, add type annotations, until you see where your mental model is failing.

This only gets harder with more type inference, as the actual issue may be far away from where the type error occurs.

This is why in haskell it is considered best practice to add type annotations to all top level definitions, at the very least.

Well... Yeah but then you lose all the advantages of static typing that he rightly acknowledges!

TSX in particular is a terrible example to make the point. It's very very similar to HTML so there is barely anything more to learn, and in return you get type checking, auto complete, go-to-definition, etc. Its so good I use it for completely static sites.

The SQL example is more compelling, but again it's really hard to paint "SQL makes static typing a bit awkward" as "dynamic typing is great here!". Like, you shouldn't be super happy about walking across a desert because your car can't drive on sand. "Look how simple legs are, and they work on sand".

That said, the SQL example does suck. I'm surprised nobody has made something like sqlx that generates the Rust types and does all the mapping for you from your schema. That's clearly the way to go IMO.

> Like, you shouldn't be super happy about walking across a desert because your car can't drive on sand. "Look how simple legs are, and they work on sand".

I didn't say I was happy about it—I just said I needed to get across the desert!

The thing I think most arch enemies of dynamic typing miss is the underlying economics of writing software.

~98% of software gets thrown on the trash heap before it reaches the point where it really needs to be maintained.

That last 2% of gold dust desperately needs to be maintained well and to work, but to get to the point where you have that asset you'll probably find you need to throw away an awful lot of code first.

If you write that 98% using static typing it will slow you right down - potentially to the point where you dont even get to the gold dust.

If you write that 2% with dynamic typing it will fall apart. A lot of programmers see this process without ever appreciating the fast prototyping that got it there in the first place.

So, this is why Im a fan of gradual typing.

Quite an assertion to say that static typing slows people down. I find the opposite. Beyond a few hundred lines you get a ton of productivity benefits that easily outweigh the time it takes to write types down:

1. Shorter iteration time for fixing type errors, especially things like field typos, missing arguments etc. They show up immediately as I'm typing instead of having to run a test suite (and having to write an extensive test suite).

2. You can write fewer tests to achieve the same code quality. Especially with really strong type systems like Haskell and Rust. Huge time saver.

3. IDE navigation features (go-to-definition, find-all-references) are generally faster and more reliable than grepping. I'm sure we've all had the pain of trying to grep where the `process` function is defined in a large dynamically typed codebase.

4. Auto complete works reliably.

5. You can refactor things reliably, especially renaming identifiers.

6. For multi-person projects or long-lived single-person projects it means you waste less time figuring out what code does.

IMO gradual typing isn't a good idea. Adding types after the fact is much more effort because you basically have to reverse engineer the code. And you don't get the performance benefits of "true" static types. There's a reason Dart switched away from it.

Static typing is a useful constraint, but it's not the only constraint. Focusing too much on dynamic vs. static typing can make one miss the more general problem: we want code that's expressive enough to do what we want, while being constrained enough to not do what we don't.

Immutability, for example, is another great constraint that's not considered in the article, but should certainly be on your mind if you're deciding between, say, Rust and Java.

The article delves into some of the drawbacks of static typing, in that while it can be more expressive, it can also contain a lot of information that's useful for the compiler but decidedly less useful for a reader. The Rust example that loads a SQL resultset into a collection of structs is a standard problem with dealing with data that's coming from outside of your static type system.

The author's solution to this is the classic one: we just need a Sufficiently Smart Compiler™. Now, don't me wrong; compilers have gotten a lot better, and Rust is the poster child of what a good compiler can accomplish. But it feels optimistic to believe that a future compiler will entirely solve the current drawbacks of static typing.

I was also slightly surprised when templates were suggested. Surely if you're aiming for rigor and correctness, you want to be dealing with properly typed data structures.

Something that the type system should do is "make impossible states impossible" as Evan Czaplicki said (maybe others too)

We have started to use typed HTML templates in Ruby using Sorbet. It definitely prevents some production bugs (our old HAML templates would have `nil` errors when first going into production).

I have typically understood the "Sufficiently Smart Compiler" to be one that can arrive at the platonic performance ideal of some procedure, regardless of how the steps in that procedure are actually expressed (as long as they are technically correct). This is probably impossible.

What I'm proposing is quite a bit more reasonable—so reasonable that versions of it exist in various ecosystems. I just think they can be better and am essentially thinking out loud about how I'd like that to work.

I agree with this. I value immutability much more than static types. I find it eliminates a much larger class of bugs without sacrificing expressiveness.
> Unsurprisingly, the equivalent Rust code is much more explicit.

Okay, but you can do the same in dynamically typed Python, while still using familiar exception logic and not requiring the type annotation on `req` (although of course you can still use one):

  def authenticate(req):
      match req.cookies.get("token"):
          case None:
              raise AuthenticationFailure("Token not included in request")
          case cookie_token:
              pass
      match req.db.get_user_by_token(cookie_token):
          case None:
              raise AuthenticationFailure("Could not find user for token")
          case user:
              return user
Although I normally use plain old `if`/`else` for this sort of thing:

  def authenticate(req):
      cookie_token = req.cookies.get("token")
      if cookie_token is None:
          raise AuthenticationFailure("Token not included in request")
      user = req.db.get_user_by_token(cookie_token)
      if user is None:
          raise AuthenticationFailure("Could not find user for token")
      return user
Nothing ever forces you to pass "null objects" around in dynamically-typed languages, although it might be more idiomatic in places where you don't care about the reason for failure (or where "failure" might be entirely inconsequential).

The nice thing about the Rust syntax shown is that constructs like `let` and `match` allow for a bit of type inference, so you aren't declaring manifest-typed temporaries like you'd have to in many other languages.

> It's possible to write sloppier Rust than this, but the baseline is quite a bit higher.

The flip side: the baseline for Python might be low, but that's deliberate. Because there are common idioms and expectations: dictionaries have both a `.get` method and key-indexing syntax for a reason (and user-defined types are free to emulate that approach). So indeed we could rewrite again:

  def authenticate(req):
      try:
          cookie_token = req.cookies.get("token")
      except KeyError:
          raise AuthenticationFailure("Token not included in request")
      user = req.db.get_user_by_token(cookie_token)
      if user is None:
          raise AuthenticationFailure("Could not find user for token")
      return user
And for that matter, Pythonistas would probably usually have `req.db.get_user_by_token` raise the exception directly rather than returning `None`.

You can always add more checks. The Zen says "explicit is better than implicit", but I would add that "implicit is better than redundant".

> In essence, dynamically-typed languages help you write the least amount of server code possible, leaning heavily on the DSLs that define web programming while validating small amounts of server code via means other than static type checking.

Well, no; it's not because of access to the DSLs. It's because (as seen later) you aren't expected to worry about declaring types for the interfaces between the DSLs and the main code. (Interfaces that, as correctly pointed out, could fail at runtime anyway, if e.g. an underlying SQL database's column types can't be checked against the code's declared data structures at compile time.)

The main thing that personally bothers me with static typing is that, well, sometimes the type calculus is difficult. When your types don't check, it's still on you to figure out whether that's because you supplied the wrong thing, or because you had the wrong specification of what to supply. And understanding the resulting compile-time error message (which is fundamentally written in terms of what the type is) is typically harder than understanding a run-time dynamic type error (which can usually be understood in terms of what the object does).

Walrus operator: if (cookie_token := req.cookies.get("token")) is None: raise ...
Nitpick: SQL is a programming language. But for most CRUD tasks you should rely less on SQL's programming capabilities until processing information is too expensive outside the SQL server/engine. The advice is also for maintainability.
A fundamental limitation is that static analysis enforces guarantees when a compiler can see and understand all the code at the same time.

It's great when you compile large amounts of code written in the same language into a single binary.

It's not so great for calls between languages or for servers communicating across a network. To get static guarantees back again, you often need to validate your inputs, for example using something like Zod for TypeScript. And then it's not a static guarantee anymore; it's a runtime error.

Database tables often live on a different server than the server-side processes that access them, so mix-and-match between different schema versions that the compiler never saw together is possible in most systems.

To prevent this, you would need some kind of monolithic release process. That runs into lifecycle issues, since data is often much longer-lived than the code that accesses it.

Parse, don't validate. Alexis King's article by that title should be fundamental reading for all distributed application developers. Go directly from on-the-wire representation to data structure with a well-defined type, and error if you can't. Use the type system to describe all representable objects. While this results in runtime errors on parse failure on the receiver's side, an advantage is that if the sender uses those same type definitions, you can guarantee that it will always send valid data.

Schemas should be derived from the type of the representation used in the application code, or vice-versa. If there are multiple schemas in use, there should be multiple corresponding types, perhaps with ways to interconvert between them. Schema changes will be less frequent, and easier to manage, with a bit of upfront systems analysis and design, including use of a data dictionary or complete IRM repository.

The more time you invest in thinking and planning what you want to build ahead of time, the fewer bugs and changes you'll have to worry about at runtime. The payoff is almost always more than worth the investment; "we don't have time to do things the right way" implies you have plenty of time to do things the wrong way.

or just use primitive types - maps, arrays & you skip this noise. if a map key is empty then it's empty.

maybe people should watch Rich Hickey's Value of Values.

Tests are how one constrains dynamically typed programs to make sure they work as expected. I have found that adding tests to untyped python code is far better at exposing bugs than adding typing is. I like my IDE to do completion too but I tend to add typing where I think it helps most rather than everywhere.

It just seems that some people like static types and good for them. Dynamic languages are for those of us who are, perhaps, lazy and want to be free to think about how to solve the problem at a slightly higher level without having to get into details.

For me the details activate my perfectionism and that blocks me from advancing with thinking about the goal and the effort of creating an elegant set of types makes me less willing to suddenly throw everything away when I realise there's a better approach. Dynamic languages give me that ability to "sketch" until I have an answer and at that point I have to ask: do I really need this to be in a higher performance language? Once or twice the answer has been yes but working out the approach in a dynamic language allowed me to do it right in the static language first time.

What I say is that the types exist in your code whether you write them down or not.

When you don’t write them down, you’re saying, “trust me, this works.” But now the types are written down in your head (a very volatile form of memory that can’t be accessed easily by others).

One time when dynamic works well is when “prove this works” is simply not worth the time. Eg. a quick script.

> whether you write them down or not.

Note that "types have been written down" != "the program was written with static typing". See, for example, what's doable with clojure.spec.

While loose, "from the hip" code (w.r.t. typing) often correlates with the use of dynamically typed languages, we shouldn't make the mistake of assuming a total causality there.

(Also, I still find the number of people willing to put e.g. `object` parameters in their public C# APIs concerningly high. Not that one has to deal with such APIs all the time, but they're not exactly eradicated, either.)

> you’re saying, “trust me, this works.” But now the types are written down in your head

Which is only even true during the initial phases of a project. The moment the initial team shares bus numbers with other developers, the 'you' becomes a collective you, and you have three people who all believe they have it written down in their head, and often at least one is wrong.

You can make a project that relies on memorization. It will eventually fail, in one sense of the term or another. Or you can make it so that you can verify things you think you used to know about the project cheaply. But then you need to remember to check your assumptions when doing new work.

Pulling everything into the type system does lead to madness.

Luckily i'm just the mad scientist to recreate a non-async rust web stack, here's the sqlite bit if you're interested.

https://github.com/swlkr/sqltight

Bonkers but cool! I really appreciate the inclusion of the tree-sitter grammar; I think we should get more used to doing that.
I'm thinking... couldn't a zig library infer the result columns at compile time with comptime and return a proper type to it?
I really, really like this blogger’s philosophy of coding.
I agree with the author. DSLs are often more reasonable, readable, and authentic than weirdly ported versions for general purpose languages. On the other hand, language servers checking type mismatches and providing suggestions are very useful. I too hope they will become good enough so we can use the same capacity for the DSL in a general purpose language like CSS/SQL in JavaScript. They are some, but not enough.
(comment deleted)
I find statically typed code harder to read and maintain on average because interfaces tend to be more complex on average. With dynamically typed languages, there is more pressure to keep the function signatures as simple as possible as developers try to work around their mental limitations of remembering all the types.

IMO, the value of simple interfaces is enormous and yields substantial benefits in terms of separation of concerns and modularization. The benefits of such architecture make arguments around static vs dynamic types pointless.

Once you know how to architect code properly and test appropriately, it makes almost no difference whether the languages is statically typed or dynamically typed... A lot of the constraints of static typing become annoyances.

The most infuriating line I hear from proponents of dynamically typed languages is how statically typed languages make refactoring easier. It's infuriating because I know exactly what they mean but it's incorrect. Having coded with statically typed languages, I'm familiar with the feeling of refactoring code and "the friendly compiler" guiding me and reminding me of all the places were I forgot to update the code when I changed one of the types... But unfortunately, the very fact that refactorings often require updating code in so many places is a testament to poor architecture. I don't have this problem when refactoring dynamically typed codebases. On average, the code is more modular. With dynamically typed languages, people don't try to design types that cut across the entire codebase. Oftentimes, many 'refactorings' in static typed land are just type modifications which wouldn't even be a problem in the first place if a dynamically typed language was used.

HTML with interpolation is not a good idea.

You really should want a template engine that knows if the variable it's injecting goes into a tag as text or into an attribute, etc.

The problem with the webstack is that these things are easily hard, or comes to you by using an ORM, or DSLs in a generic language.

Personal I think that things like JSX or: https://www.gomponents.com/ Aren't that bad, as they are basically modeling HTML.

JSX doesn't have to mean react, you could render server side into plain html.

There are no solutions when it comes to types. Just trade-offs. And your values/experience decides which trade-offs you prefer.