How types make hard problems easy
mayhul.com
mayhul.com
> This dichotomy gels really well with the way my brain works. I’m able to channel short bursts of creative energy into precisely mapping the domain or getting type scaffolding set up. And then I’m able to sustain long coding sessions to actually implement the feature because the scaffolding means I rarely have to think too hard.
It's why I keep begging my team, every time there's a new codebase (or even a new feature), to stop throwing `any` onto everything more complicated than a primitive. It is exhausting. It forces me to waste energy on the shitty, tedious parts. It forces me to debug working code just to find out how it works before I can start my work.
They tend to take the quickest solution to everything -- which means everyone else has to do the same work over and over again until someone (me, invariably) sits down and makes a permanent record of it.
In doing this they ensure that I can't trust any of their code, which is counterproductive for what should be obvious reasons. Every time I work on established, untyped (or poorly typed) code, it's like I'm writing new code with hidden, legacy dependencies.
Without this, Typescript is next to useless. Not knowing if the types are good is worse than no types at all.
* Common functions such as parsing functions in languages that don't support function overloading
* "equals" and other global functions.Types that are too complex... hmmmm - I'm sure this exists in domains other than the bullshit CRUD apps I write. So yeah, I guess I don't know what I don't know here. I've written some pretty crazy types though, not sure what TypeScript is unable to represent.
I'd be interested in similar capabilities for higher-level tools like static analyzers and so on. The point is not to carry violations long term, but to be able to burn down the violations over time in parallel to new development work.
I want a type that represents a string of negative prime numbers alternating with palindromes, separated by commas.
You could try to craft your own type to match google's schema or hunt down 3rd party types, but just doing `(window as any)["__grecaptcha_cfg"]` gets the job done much faster and it's fairly isolated from the rest of the code so it doesn't matter too much.
But for your own handwritten application code, there is no excuse to use `any`.
Generally, the conveniences of allowing any are swamped by the mess it accumulates in a typical team.
You'd need to reach for something like https://typescript-eslint.io/rules/no-explicit-any/
The former like to brag about how quickly they can go from zero to a working solution. The latter brag about how their solutions have fewer bugs, need less maintenance, and are easier to refactor.
I am squarely in the latter camp. I like strong and capable type systems that constrain the space so much that—like you say—the implementation is usually rote. I like DSLs that allow you to describe the solution and have the details implemented for you.
I personally think it’s crazy how much of the industry tends toward the former. Yes there are some domains where going the time from zero to a working product is critical. And there are domains where the most important thing is being able to respond to wildly changing requirements. But so much more of our time and energy is spent maintaining code than writing it in the first place that upfront work like defining and relating types rapidly pays dividends.
I have multiple products in production at $JOB that have survived nearly a decade without requiring active maintenance other than updating dependencies for vulnerabilities. They have had a new version deployed maybe 3-5 times in their service lives and will likely stay around for another five years to come. Being able to build something once and not having to constantly fix it is a superpower.
First off the declaration is better as it’s less error prone but it comes at the cost of being harder to write and harder to interpret.
Imagine if we communicated with one declarative run on sentence. No paragraphs at all…
Procedures are default and easier to our nature as humans.
I agree with your observations, but I'd suggest it's not so much about domain (though I see where you're coming from and don't disagree), but about volatility and the business lifecycle in your particular codebase.
Early on in a startup you definintely need to optimize for speed of finding product-market fit. But if you are successful then you are saddled with maintenance, and when that happens you want a more constrained code base that is easier to reason about. The code base has to survive across that transition, so what do you do?
Personally, I think overly restrictive approaches will kill you before you have traction. The scrappy shoot-from-the-hip startup on Rails will beat the Haskell code craftsmen 99 out of 100 times. What happens next though? If you go from 10 to 100 to 1000 engineers with the same approach, legibility and development velocity will fall off a cliff really quickly. At some point (pretty quickly) stability and maintainability become critical factors that impact speed of delivery. This is where maturity comes in—it's not about some ideal engineering approach, it's about recognition that software exists to serve a real world goal, and how you optimize that depends not only on the state of your code base but also the state of your customers and the business conditions that you are operating in. A lot of us became software engineers because we appreciate the concreteness of technical concerns and wanted to avoid the messiness of human considations and social dynamics, but ultimately those are where the value is delivered, and we can't justify our paychecks without recognizing that.
We way overindex on the first month or even week of development and pay the cost of it for years and years thereafter.
If you always maintain a high standard you get better and faster at doing it right and it stops making sense to think of doing it differently as a worthwhile tradeoff.
Is it worth spending a bit more time up-front, hoping to prevent refactoring later, or is it better to build a buggy version then improve it?
I like thinking with pen-and-paper diagrams; I don't enjoy the mechanics of code editing. So I lean toward upfront planning.
I think you're right but it's hard to know for sure. Has anyone studied software methodologies for time taken to build $X? That seems like a beast of an experimental design, but I'd love to see.
I think you'd be hard pressed to find a team that lacks this kind of cooperation and maintains consistently high quality, regardless of what some nontechnical project manager says or does.
It's also an individual effort to build the knowledge and skill required to produce quality code, especially when nobody else takes responsibility of the architectural structure of a codebase, as is often the case in my experience.
I think that in order to keep a codebase clean you have to have a person who takes ownership of the code as a whole, has plans for how it should evolve etc. API surfaces as well as lower level implementation details. You either have a head chef or you have too many cooks, there's not a lot of middle ground in my opinion.
To make things more complicated, programmers need practice to become fluent and efficient with any particular best practice. So you need investment in those practices in order for the cost to be acceptable. But some of those things are context dependent. You wouldn’t want to run consumer app development the way you run NASA rover development because in the former case the customer feedback loop is far more important than being completely bug free.
I try to design the code in a modular way. Instead of trying to predict future requirements I just try to keep everything decoupled and clean so I can easily make arbitrary changes in the future. Some times a new requirement might force me to make large changes to existing code, but most often it just means adding some new stuff or replacing something existing that I've already made easy to replace.
For example I almost always make an adapter or similar for third-party dependencies. I will have one class where I interact with the api/client library/whatever, I will avoid taking dependencies on that library anywhere else in my code so if I ever need to change it I'll just update/replace that one class and the rest of my code remains the same.
I've had issues in codebases where someone else doesn't do that - they'll use some third-party library in multiple different components and practically make the data classes of that library part of their own domain and have workarounds for the library's shortcomings all over the place so when we need to replace it or an update contains breaking changes or something like that it's a big deal.
There's a lot of things like this you can do that don't really take much extra time but makes your code a lot simpler to work with in general, makes it a lot easier to change things later etc. It has lots of benefits even if the library never gets breaking changes or needs to be replaced.
Same thing for databases, I'll have a repository that exposes actions like create, update, delete etc and if we ever need to use a different db or whatever it's easy. Just make a new repository implementation, hook it up and you're done. No SQL statements anywhere else, no dependency on ORMs anywhere else, I have one place for that stuff.
When I organize a project this way I find that nearly every future change i need to make is fairly trivial. It's mostly just adding new things and I have a place for everything already so I don't even need to spend energy thinking about where it belongs or whatever - I already made that decision.
With no type-checking you need to gamble testing all codepaths.
It's because most people who use technology literally don't care how it works. They have real, physical problems in the real world that need to be solved and they only care if the piece of technology they use gives them the right answer. That's it. Literally everything programmers care about means nothing to the average person. They just want the answer. They might care about performance if they have to click the same button enough times, and maybe care about bugs if it's something that is constantly in their face. But just working is enough...
Quality, maintainability, etc, is less important in that moment. And, like many things in companies, short term desires dominate.
A poorly typed program often would give a wrong answer, or, in a less dangerous case, crash.
Same for physical parts: you want them to solve some business problem, but often you won't go for the absolute cheapest, because they may work poorly.
"Poorly typed" means different things to different people, in the context of this article and thread it would probably mean weakly typed or dynamically typed? Which has nothing to do at all with the correctness of a formula or what output a program will produce.
I love strong types. I love for loops. I love stacks.
GP! Try Rust. Imperative programming isn’t orthogonal to types. You can go hard in Rust. (I loved experimenting with it but I like GC)
GP! Try data driven design. Imperative programming isn’t orthogonal to declarative.
Real talk, show me any declarative game engine that’s worth looking at. The best ones are all imperative and data driven design is popular. Clearly imperative code has something going for it.
and the advantages aren’t strictly speed of development, but imperative can be clearer. It just depends.
Neither is strongly wrong or right, better or worse. They have different strengths in different problem areas, though I do think we’ve swung far too hard toward the procedural approach in the last decade.
I just find it odd to analogize:
> agile : waterfall :: imperative : declarative
A strong type system is your knowledge about the world, or more precisely, your modeled knowledge about what this world is or contains - the focus is more on data structures and data types, and that's as declarative as it can get with programming languages(?). I'd also call it to be holistic.
A procedural approach focussed more on how this world should be transformed - through the use of conditional branching and algorithms. The focus feels to be less on circumstances of this world, but more to be on temporary conditions of micro-states (if that makes any sense). I'd would call it to be reductionistic.
The two approach have their own cons and pros. But aren't explicitly exclusive. Sometimes the goal and solution aren't that clear. So you do it procedurally until you find a POC(Proof of concepts) that may actually solve the problem. And refine it in a declarative way.
You can't write a program without knowing that x is a string or a number, your only choice is whether you document that or not.
for a really simple example: languages which allow narrowing a numeric to a float, but also let you interpolate either into a string without knowing which you have.
A statically typed Console.log in JS/TS would be an unnecessary annoyance.
> those who default to writing every program procedurally and those who default to doing so declaratively.
What is the difference between programming procedurally and programming declaratively? I saw these terms used this way.I would say that imperative is the one that does computation in steps so that one can at each step decide what to do next. Declarative normally lacks this step-like quality. There are non-languages that consist solely of steps (e.g. macros in some tools that allow to record a sequence of steps), but while this is indeed imperative, this is not programming.
One side cares more about how the solution is implemented. They put a lot of focus on the stuff inside functions: this happens, then that happens, then the next thing happens.
The other side cares more about the outside of functions. The function declarations themselves. The types they invoke and how they relate to one another. The way data flows between parts of the program, and the constraints at each of those phases.
Obviously a program must contain both. Some languages only let you do so much in the type system and everything else needs to be done procedurally. Some languages let you encode so much into the structure of the program that by the time you go to write the implementations they’re trivial.
-- my attempt:
Imperative defines the order and semantics of each step.
Declarative defines the prerequisites and intent of each step.
The algorithms each can implement are equivalent.
https://existentialtype.wordpress.com/2013/07/18/what-if-any...
Semantic is another word that is hard to define.
I find imperative better for expressing state machines. I find declarative better for backtracking.
You can write a state machine with just a loop, an assignable, and conditions. Writing state in prolog is irritating.
Similarly declarative programming is strictly secondary to imperative. It is a limited form of imperative that codifies some good patterns and turns them into a structure. But it also makes it hard or impossible not to use these patterns.
Of course declarative programming is simpler and less error prone. But it is also essentially inflexible. The implicit instruction is finite and will inevitably run into a situation when the baked execution logic does not quite fit. It will be either inefficient or require a verbose and repetitive parameter, or just flat out incapable of doing what is desired. In this case declarative programming fails; it is impossible to fix unless we rewrite the underlying instruction.
E.g. 'printf' is a small example of declarative programming. It does work rather well, especially when the compiler is smart about type checks, but once you want to vary the text conditionally it fails. (The thing that replaces 'printf' are template engines that basically reimplement same logic and control statements you already have in any language and the engine works as an interpreter of that logic. The logic is rather crude and limited and the finer details of formatting are left to callbacks that are mostly procedural.) For example, how do I format a list so that I get "A" for 1, "A and A" for 2, and "A, A, and A" for more? Or how I format a number so that the thousand separator appears only if the number is greater than 9999? Or what to do if I have an UTF-8 output, but some strings I need to handle are UTF-16? The existing declarative way did not foresee these cases and to add them to the current model would complicate it substantially. But if I have a simple writer that writes basically numbers and strings I can very quickly write procedures for these specific cases.
Instructions are primary by their nature. A piece of data on its own cannot do anything. It always has an implicit instruction that will handle it. So instructions are the things we have to master.
In comes Rust. I know the memory management is the thing that sells it, but in an embedded project, I'm simply not doing any dynamic memory allocation, so I didn't think I'd see much benefit. I mostly tried it because Cargo is an amazing build system. But the thing that has sold me on it is the type system. Within my first 5 minutes of porting a magnetic encoder driver, the type system caught an error in my ported code. I used the wrong pin for my SPI MOSI connection to my driver. It absolutely blows me away that the type system knew I was using the wrong pin. Turns out the code I could never get to work in C was broken because I was referencing the wrong pin, and I never knew why. Fifteen minutes later, it caught another error: by sharing the SPI bus, it could identify that there were more than two devices connected, because there were two CS pins declared, and because the device wasn't exclusive, I had to wrap the bus in a refcell for memory safety. Absolutely amazing.
I never thought that embedded programming could be fun, and strong types were what changed my mind.
Of course, the resource constraints and efficiency of the compiled code play a significant role here. So unless a new memory efficient strict-typed language/compiler comes along with a modern mindset, the power of type will not come into play.
I will say that the coroutine-based async facilities in Rust's embedded-hal and embassy-rs projects make task-based concurrency so easy to do with bare-metal code that there isn't much need to use an RTOS in many of the situations that a C/C++ project could do. That sort of OS-less concurrency is no doubt possible in C, but I shudder to think what it would look like.
The type system in C allows you declare the pins as enums with literal bitmaps and declare functions that take only those enums to perform logical bitwise operations.
Trouble is, the libraries that (probably) came with your system (ESP32, or whatever) have these things defined as macros.
Still, you can have this type of type-safety at this level, and get compile-time errors when doing the wrong thing.
> Fifteen minutes later, it caught another error: by sharing the SPI bus, it could identify that there were more than two devices connected, because there were two CS pins declared, and because the device wasn't exclusive, I had to wrap the bus in a refcell for memory safety.
This is also something that can be done within the C typing system so that you get compile-time errors.
> I never thought that embedded programming could be fun, and strong types were what changed my mind.
Good for you. You should note, however, that none of these two scenarios you listed are specific to Rust - you could have solved both those issues by leaning on the typing guarantees already in C.
A big problem is that using the escape hatches in C to break the typing guarantees are not really frowned upon, so much of the code you see does extra work to break the typing.
And then newbies come along, look at the code that does extra work to break the type system, and conclude that the type system can't prevent some class of problems.
The reality is that, until and unless you put in the extra effort to break the typing (such as throwing around casted void pointers), you will get quite a lot of compiler errors when you mismatch types in C.
Admittedly, a lot of this complaint with C/C++ might not be technical but cultural. The pervasive use of macros and templates might have an origins in avoiding some technical painpoint of the languages or compilers, but they have a real effect in how they are used. For example, I didn't know that C had consts until I started reading K&R...real world usages in open source code almost always use define.
And the same goes for rust...for example, the extremely restrictive type system of Rust means that a lot of effort has gone into making type errors extremely helpful, so that you don't end up wrapping your whole program in an unsafe block. In C, you are told that you have a type error, but in Rust, you are told why, and given extra context, and in common cases you are pointed towards what you probably wanted.
An example is receiving API response with `fetch`. Normally you'd cast the response to the expected type, but it's not uncommon for you to misunderstand what the API can return or for the API to be changed/updated. Zod lets you verify your types at runtime so that if your API returns something unexpected then you can act on it.
This is really useful anytime you're interacting with I/O or user input. For example, I've used Zod for: loading from JSON files, reading from local storage, parsing URL params, or validating form input.
[0]: https://zod.dev/
const natural = pipe(string(), transform(atoi), minValue(0))
const percent = pipe(natural, maxValue(100))
[1]: https://valibot.dev/guides/pipelines/Worked perfectly first time, because the type system allowed compiler to tell me everything that was broken.
[0] https://aphyr.com/posts/342-typing-the-technical-interview
People make huge projects in them and start learning new techniques and then the weight of the choices they made begin to grow.
Enter: Types, a frequent savior. Not always the best choice for everyone, but a very good and useful thing.
I welcome this person on their journey!
All three of those languages have ways to use typing, so this statement is only true in the sense of types not being mandatory, which is also the case in any language that has the Any type.
If you want to skip out on types in Typescript, you have to be explicit about it.
> How types make easy problems hard
Sure that's sometimes an issue with the type system itself (looking at you Sorbet) but also preventing the programmer from taking advantage of the flexibility, expressiveness and elegance the language itself my add (yes, Ruby).
But even Typescript, which is arguably one of the better type system/language pairings out there, often causes more headache than it's worth.
The problem with complex TS types is that there's no debug tooling. You can't set a breakpoint, you can't step through the type propagation. If there's an issue you have to just do trial and error, which can be very tedious.
Here's a super complex type I ran into in the wild that has a bug that is extremely difficult to fix unless you burn hours on trial and error: https://github.com/openapi-ts/openapi-typescript/blob/main/p...
(the bug in question, still unfixed): https://github.com/openapi-ts/openapi-typescript/issues/1769
It was so hard to fix this bug that I found it easier to just rewrite the entirety of the library but using code generation instead of ultra complex TS types to accomplish the same outcome.
JavaScript was a bad trip, guys.
When you have a team of engineers, this means your entire team needs to either learn or lean on an expert when tougher situations arise.
It would be unfair to consider your team incompetent just because they are experts with another set of tools. It’s also unreasonable to expect these things to be quickly learned (TypeScript types are not friendly). But I think it’s reasonable to explain the benefits of this approach and to help your ramp up and learn the skill.
But, anyway, I understand the frustration. I’m usually the one trying to get my team to understand the value of modeling problems in type systems.
Is there really that much legwork otherwise? Adding ": string" to a function parameter assumes they know what a string is (which should already be the case), adding an object type assumes knowing what an object is, etc.
Simply adding types is usually not too difficult and it is still quite beneficial. It does eliminate certain kinds of bugs.
Modeling your application in a type system means making invalid states unrepresentable and being as precise as possible. This is a lot more work, but again is eliminates more kinds of bugs that can occur.
An example of this being complex: earlier this week I wrote a generic React component that allows users to define which columns of a table are sortable. I wanted to prevent any invalid configurations from being passed in. This is what it looks like: https://tinyurl.com/bdh6xbp6
It's a bit complex but the compiler can guarantee that you're using the component correctly. This is more important and useful when it comes to business logic.
All the type system does is make that implicit knowledge explicit, and a long the way, stops you from doing things that are likely to cause issues.
1. Added Boilerplate and Ceremony: Simple tasks may require extra type declarations and structures, adding “ceremony” that feels unnecessary for quick one-off solutions.
2. Rigid Type Constraints: Combining different data types or working with unclear data shapes can force complex type solutions, even for simple logic, due to strict compilation rules.
3. Complex Type Definitions for Simple Data: Handling semi-structured data (like JSON) requires elaborate type definitions and parsing, where dynamically typed languages let you manipulate data directly.
4. Refactoring Overhead: Small changes in data types can cause widespread refactoring, turning minor edits into larger efforts compared to flexible, dynamically typed environments.
5. Complexity of Advanced Type Systems: Powerful type features can overwhelm trivial tasks, making a few lines of code in a dynamic language balloon into complex type arguments and compiler hints.
A risk is, unexpected data (empty field instead of zero; a real number introduced in untested corner cases where only an integer will actually work etc) can cause issues after deployment.
Those 'complex' requirements mean, if you want a reliably correct program well then you'll have to put in this much work. But go ahead, that 'trivial task' may become something less trivial when your task fails during Christmas sales season or whatever.
The real question IMO is are the benefits worth the cost.
Group A writes a couple of tasks without typing. Group B writes it with typing. Compare development times, execution times, quality, security, etc...
Sure, primative types are fine, but anything more and it's a massive pain.
Actually, types are best when someone else already did all that work.
This goes to the heart of what's not great about this, types impose global semantics on a piece of software, they introduce coupling. (It's why Alan Kay used to stress "late binding of all things") as a feature of managing complexity.
In fact one result of this kind of programming were microservices. What do they do? Reintroduce runtime dynamism. It's not often framed that way but there's a reason you see more statically typed microservices than Lisp or Erlang ones. It's because they're an attempt to get away from the coupling imposed by type driven programming and towards more independence of each service. Which is already baked into message based, dynamic languages.
And there's also a fundamental misunderstanding about data and types in the article.
> Making our types represent the “truth”
Types can't represent truth. Real world data doesn't have types. It changes incrementally however it wants, and all the time. You can use types to not let something you don't want into your program, but you can never represent arbitrary real world data by matching types onto them.
What do you mean? If a variable is a date/time or an integer etc. in the real world it should stay that way and be represented as such in software.
Same with a lot of systems like say a search system that tracks data using the best embedding, well we don't know what that best embedding is in terms of price performance and encoded knowledge perf it's just a moving target.
Send me the system that tracks important metrics effecting the stock market, or a weather system etc... the sensors are all updating and then an entirely new thing comes along like Starlink that helps us track the weather in totally new ways
As someone who is still learning this is a huge reason why I've come to love dynamic languages. Any project I do involves a lot of rewriting as the code evolves, I found that trying to predict ahead of time what the structures and types will be is mostly a waste of time. The best middle ground for me so far has been using python with type hints. It allows for quick iteration, I can experiment and only then update the types to match what I have, so that I can still get LSP help and all that.
But I could see this being less relevant with more experience
But yeah if you use something like Zod you can at least say "this is what I'm pretty sure the API should return" but also define what should happen if things change / don't meet your types.
I think that's why the article references the 'parse, don't validate' article: Real world data is messy and so you ingest it into your business logic at the system boundaries via parsing.
That's like saying "Maps can't represent truth". They're a model that works well enough, just like types do, if you do it right.
That's true for static types... but it's just as true for the constructs in your dynamically typed code! Nothing about dynamic typing makes your logic or data representation any more adaptable, it just makes the rigid models inherent to your code implicit rather than explicit.
E.g.: “In a reflective environment such as Smalltalk, any change to the system can be detected by the system. Therefore, it is possible to write programs that depend on the objects being a particular size, or that call methods by getting a string from the user and calling perform: with it. Therefore, it is impossible to have totally correct, nontrivial refactorings. However, the refactorings in our system handle most Smalltalk programs, but if a system uses reflective techniques, the refactorings will be incorrect.”
“To correctly rename a method, all calls to that method must be renamed. This is difficult in an environment that uses polymorphism to the extent that Smalltalk does. Smalltalk also allows dynamically created messages to be sent via the perform: message. If an application uses this approach, any automatic renaming process has the potential of failure. Under these conditions, guaranteeing the safety of a rename is impossible.
“The Refactoring Browser uses method wrappers to collect runtime information. […] Whenever a call to the old method is detected, the method wrapper suspends execution of the program, goes up the call stack to the sender and changes the source code to refer to the new, renamed method. Therefore, as the program is exercised, it converges towards a correctly refactored program. […] The major drawback to this style of refactoring is that the analysis is only as good as your test suite. If there are pieces of code that are not executed, they will never be analyzed, and the refactoring will not be completed for that particular section of code.”
http://www.laputan.org/pub/papers/opdyke-thesis.pdf
As-for "performing large changes" --
"A very large Smalltalk application was developed at Cargill to support the operation of grain elevators and the associated commodity trading activities. The Smalltalk client application has 385 windows and over 5,000 classes. About 2,000 classes in this application interacted with an early (circa 1993) data access framework. The framework dynamically performed a mapping of object attributes to data table columns.
Analysis showed that although dynamic look up consumed 40% of the client execution time, it was unnecessary.
A new data layer interface was developed that required the business class to provide the object attribute to column mapping in an explicitly coded method. Testing showed that this interface was orders of magnitude faster. The issue was how to change the 2,100 business class users of the data layer.
A large application under development cannot freeze code while a transformation of an interface is constructed and tested. We had to construct and test the transformations in a parallel branch of the code repository from the main development stream. When the transformation was fully tested, then it was applied to the main code stream in a single operation.
Less than 35 bugs were found in the 17,100 changes. All of the bugs were quickly resolved in a three-week period.
If the changes were done manually we estimate that it would have taken 8,500 hours, compared with 235 hours to develop the transformation rules.
The task was completed in 3% of the expected time by using Rewrite Rules. This is an improvement by a factor of 36."
from “Transformation of an application data layer” Will Loew-Blosser OOPSLA 2002
You could just as well say "how bad APIs make simple problems complicated and how you might strain at a type system to pretend this bad design is worth keeping."
I mean, "string or boolean or undefined," is not at all a "type." This is a poorly specified contract for an overloaded interface with terrible semantics built on the abuse of language grammar.
It's why I think a language with the core semantics of JavaScript plus a goofy type system are never going to produce anything worth actually having. The two sides of the system are constantly at odds with each other. People are mostly using the type system to paper over bad design semantics.
I shuddered.
As a mobile app? Maybe not.
I'm honestly a bit puzzled which scenario the original article is envisioning when arguing this, because it mentions mobile, but then also argues for monorepos, which are kind of at odds with each other, unless you somehow force your mobile users to always be using the version that matches your back-end.
To be fair though, this is a general problem when clients and servers might not agree on data formats. You can still safely do what the author is describing since type checking occurs at compile-time and not runtime.
You would, of course, need to be sure your app handles whatever the API/db returns at runtime though. But, again, this is a general problem.
A lot of people, especially people from the same background, would benefit from branching out and learning some other programming languages/paradigms.
Some... statically typed and actually compiled languages. Maybe to take an entry course in CS.
It's very popular to hate on formal education, especially in software, but all these lessons would have been learned in the first semester or two.
Don't get me wrong, I'm not hating on JS here, and I have lots of beef with C++, but I fully agree with your take that TS barely scratches the surface of the statically typed world.
Most cases I've seen with more complex interfaces is due to the fact that it is what the interface truly expects. Usually making it simpler tends to mean it's actually wrong or incomplete.
You're asking me to tell on my coworkers, and I'm too loyal to throw them under the bus :)
Well, OK, here's one, but I'll keep it as blameless as possible. We had a thing where we wanted to register some event handlers. The primary use of these event handlers was to run a selector, and if the selected data changed, trigger an update, passing the selected data along. The initial implementation used existential types to store a list of callbacks, each returning different selected data. The "driver" then did the equality checking and update triggering. We later changed this, so that the callbacks - as far as the driver was concerned - all returned `void`, eliminating the need for an existential type. We just had to move the equality checking and update triggering to inside the callbacks.
Some features are straightforward translations: anywhere you have overloading and/or optional arguments you can (and often should) simplify by refactoring into multiple functions.
For a concrete, public example...well, I remember the Uppy library had a lot of stuff like this. A lot of work goes into making it's "Plugin" interface look the way it does (start at [1] and keep reading I guess) for instance, and while I haven't sat down and re-engineered it I don't think it needs to be this way, if you're willing to give up some of the slickness of the interface.
[1] https://github.com/transloadit/uppy/blob/main/packages/%40up...
That sounds a little reductive and gate-keepy. Maybe an advanced type system allowing for complex types to be expressed easily actually allows you to write simpler, more effective code.
Curious if you have any specific examples though.
The more you lean into crazy ass generics in your library, the simpler and more error-free the user can make their biz logic code. Really nicely typed libraries almost give you zero chances to fuck things up, it’s amazing.
But then again most of your devs wont be able to understand all those generics, so you need to keep your biz logic types relatively simple.
Honestly I think it’s the most interesting one to work with, too. Which is not always a good thing, but it is fun.
The only type systems I’ve seen that are similarly expressive are rust and Haskell. Even go doesn’t come anywhere close.
Modern Java is an entirely different beast, which feels very functional these days.
(and yes, I say this as someone who teaches these things as part of an undergrad java course)
Even though the language is getting less painful, frameworks like Spring that do things at runtime instead of compile time, including rewriting bytecode on startup to inject code, make the ecosystem quite hostile to folks who want to work in a stricter, safer manner that’s easier to reason about(expressed in the language, not in some annotation based metalanguage with no principles and whose implementation changes randomly).
We need to stop defending Java and move on to something actually modern and good. Scala has fallen from favor, so maybe Rust is the next thing I’ll try.
I think most of what people use Lombok for though are features that should be part of the core language by now, or would be better as library methods instead of annotations. Like generating constructors, equals, and hashCode methods - case classes and data classes in Scala and Kotlin respectively handled that within the language spec many years ago. I need to try Java’s new Records, perhaps they handle that stuff now. Lombok and friends also include features that change language semantics like @SneakyThrows.
Byte code injection sometimes also changes language semantics. Early in my career I spent a few hours perplexed by why my code was encountering null when the code path I was examining used only non-nullable primitives. Turned out injection and rewriting had turned my primitive long into a nullable Long. I don’t like not being able to understand my code from just reading the code. The magic means I have to be aware of spooky action at a distance mechanisms and review their documentation. I also need to open the debugger more regularly to inspect what’s actually happening at runtime instead of just mentally compiling my code.
e.g. sealed classes are effectively sum types for java; records are effectively product types, and switch expressions now can do pattern matching at the record field level, with added guards. streams give you a mechanism for tail recursion with tail-call optimisation, etc. there's a nice little section on dev.java "moving to functional" (or something like that).
I completely agree. I started reading the article expecting to read something interesting or smart about functional programming,but it turns out the blogger is just very vocal at telling the world their excitement over reinventing the wheel and being completely obliovius to what actually represents very basic things in any intro to software engineering course.
[1]: https://cstheory.stackexchange.com/questions/50780/which-uni...
The blogger is not a really talking about static typing. The blogger is waxing lyrical over designing a domain model and then writing an application around it. You know, what others call basic software architecture.
Wait until the blogger learns of the existence of Domain-Driven design.
I found it a good and well-reasoned explanation of _why_ he enjoys types in a large codebase. He does take time to explain different type concepts, but I assumed that was because he doesn't assume his audience is familiar with all of them. Considering that the opinion "types are good and helpful in a codebase" is not universally held, even by very experienced/productive coders (see https://world.hey.com/dhh/turbo-8-is-dropping-typescript-701... or basically any ruby codebase), I think articles like this have a definite place.
The reason why is because most formal education curriculums teach C++ which ironically is more error prone and contains error conditions far harder to debug then untyped interpreted languages like python or JavaScript or ruby which were coming into popularity at the time. This is of course despite the fact that C++ has a type system with generics.
Because of this, a lot of people tended to associate typing with something more error prone and harder to work with. It wasn’t until the advent of typescript, golang and rust when people started to get the difference.
prices: NonEmptyArray<Price>
Doesn't this mean you can't ever remove an element from the array, only add to it?Like, the compiler should throw a type error if you try to pop() from an array with one element.
TS should say, you can't pop() this array, unless TS can infer it has >1 elements. Otherwise it can enter a state at runtime which doesn't conform to the type. That seems bad!
I’m surprised there’s not been any mention of effect (http://effect.website/) yet, as it is kind of the next level up if you really want to model things like errors, dependencies and side effects in the type system, using functional concepts borrowed from more pure functional languages.
It would be a bit of a risk adopting this into a shared code base depending on your team and the kinds of devs you’re looking to hire, but it could be useful to some folk that feel like they want even more type safety.
> type NonEmptyArray<T> = [T, ...T[]];
I never used TypeScript before, but this looks very useful. Is that possible in C+++ templates or Java generics or C# generics?For instance, if we just declare some data structures for computational geometry, like points, line segments and whatnot, code for, say, intersecting two meshes is not effing going to write itself!
The author is living in some CRUD world of pulling things from one API or database, converting to a different data model, and stuffing them into another API, with maybe some HTML generation sprinkled on top.