Posted by cloud8421 23 hours ago
I tried it out and, although I do miss static types sometimes, immutability and not having to deal with inheritance and other OO abstractions has made the trade-off worth it for me.
Yes some people do claim that pattern matching makes up for the lack of static types. I don't agree with that, but can say that anecdotally the number of type related bugs I notice in *my* Elixir code is much lower than the number of similar bugs I used to write in languages like Python. Whether that's because of common usage of pattern matching, or community adherence to patterns like returning tuples of {:ok, result} | {:error, error}, or something else is anyone's guess.
An important point not in the heading is that gradual typing has been added without any new language syntax.
It's still not statically typed. Maybe it never will be, but this is a step in the right direction and at least they're trying.
In contrast, Go's message passing model works on typed channels. A channel has a type, and only accepts messages of the given type. The `receive` operator then acts as the merging data flow which solves the problem of receiving messages of different types. This is a design which amends itself far better to static typing.
Pattern matching isn't a substitute for static typing at all. The two features are entirely orthogonal indeed, and you definitely want static typing and pattern matching at the same time.
> Bad APIs, bad UIs because someone coupled themselves to the database structure and can't escape.
If you don't commit yourself to the database structures you defined at the time of application creation, then it just reflects poor planning and architecture overall as that is one of the very first things you do.
What you describe is an approach a lot of NoSQL fans use - use whatever works then, worry about datatypes later on. That's how you shoot yourself in the foot.
> List of memberships? Keep them as a list with the same fields
Again, using embeds_many or has_many works well too, using changesets - which is my point exactly. Not sure where the disagreement is here.
Your account is full of just ragebait comments at a quick glance, so I'm just going to leave it here.
I obviously don't know your specific use case, but in my experience having the database schema reflect throughout a project means its either very small or the design is going to run into problems.
It also sounds like a potential security nightmare. We have a policy of never sending domain objects across the wire so nothing accidentally gets sent. APIs must strictly whitelist data structures.
The way this can work in something like an Elixir or Clojure: you have gradual types in most of the core code, but you translate it just before you hit the view layer (e.g. templates).
The great thing about dynamically typed languages is you don't have to declare a new type for each view. You just select out the data you need and expose it for the view. In Clojure this is as simple as a select-keys.
No it reflects the reality that requirements and applications evolve over time. You sound like someone who's never supported an application for more than 5 minutes.
If your application requirements change every 5 minutes, then you prove my point - you suck at architecting and should honestly just give your job away to someone more competent.
Which is why you architect before-hand with a paradigm of your choice, like DDD (Domain Driven Design) using proper contexts (which Phoenix supports) beforehand. That is the sign of a mature developer, not the other way around.
If your datatype for a column evolves over time to completely different types, it's just an excuse for poor planning and architecture. Eg. A string turning into an integer. That just sounds like someone junior would do with MongoDb.
> You really sound like someone who only does CRUD services.
You throw this like an insult, but in reality most applications can be simplified to just CRUD services. Chat interfaces? CRUD. Social Media? CRUD. Banking? CRUD.
This lets you evolve each part independently and use the "native" types frontend vs backend, which happens surprisingly frequently as the app grows
You're not wrong and most other comments are responding this from some sort of UI library perspective, like React / Svelte. However, if you're using even the barebones scaffolded UI using LiveViews from Phoenix, you don't have to do any of these. Phoenix will wire up the form to the changesets by default. Which is what I'm referring to.
Please don't use changesets to enforce some kind of type system between system components. In case you do not trust your own code, Elixir is strongly typed (though not static typed), there are test cases, there's dialyxir and if still you cannot stop yourself from passing a number where a string will do, the process will crash, log a message for you to fix the bug, and get restarted by a supervisor.
I get why people are obsessed with static typing on "normal" languages, where bugs cause system downtime, but the Erlang platform gives you so many guarantees that even if you somehow make a mistake, it is never catastrophic. Gradual typing in Elixir is a nice cherry on top of the runtime, not the cornerstone to robust OTP software.
The runtime costs aren’t trivial, especially on large datasets, but I’ve come to love this pattern a lot.
There's also a balance between learning new languages for fun and for the insights they give, and wanting to ship.
As an example: Prolog was mind-bending for me when I tried it and I had a lot of fun with it, but I can't imagine using it to build a product (I'm sure other people have though).
Perhaps my first comment sounded more critical than intended. I'm really excited to see where this initiative with set-theoretic types goes, and if it leads to a fully statically typed language then that will be a bonus. If that doesn't happen, then I'm still perfectly happy with the language as it is.
Elixir taught me that I don't need static types as much as I thought.
One use is a spellcheck. Though some bits are in Rust cause backtracking would be too slow.
Another is a game I'm making, the server is in Elixir, and I use erlog to basically program the NPCs in prolog. The game generates events and they are processed into facts if they are perceived by the character.
And with that I can have the system generate goals based on stuff like "I havent seen X at the market for 3 days whilst beforehand I saw X every day. Let me go check on X."
I didn't know Erlang started as a Prolog program basically, but it shows cause they fit together like a match made in heaven.
What I mean by that is, I used to write JS. Transitioning to TypeScript didn't alter my mental model of the language.
Likewise for Python with type annotations.
The only time I've had that happen is with Scala 3's dependent types/type lambdas, but thats LITERALLY called "type-level programming", so it makes sense.
That said, I would love to know how the state of what's in v1.20 compares to un-spec'ed dialyzer. I was under the impression that dyalizer's "success typing" approach (not flagging a function if there are some combination of parameters such that it works, rather than flagging it if some combination of parameters can make it fail) was like what Elixir is doing here, and I haven't found dialyzer terribly useful.
I haven't had it catch something before the compiler in a while. I still use typespecs for their documentation benefit, though I've been using `defguard` w/ `is_struct/2` and complex guards a lot more in recent years.
I spent 3 months analyzing failures caused by - what looked like - dirty builds but was caused by unstable compilation order. Which is quite obvious.
The solution is dynamic dependency resolution but this causes problem with macros.
The problem is easy to validate. Compile application multiple time and compare hashes. I'm not sure if it's sufficiently visible in bootstrapped Phoenix but I saw it in as small as <1000 LoC toy apps.
https://github.com/phoenixframework/phoenix/issues/6697
In case you want to see files affected I made extended writeup on my blog - for reference. https://xlii.space/eng/elixir-cycles/
Does Dialyzer understand Elixir? Last I knew, it could only process Erlang source code and BEAM files. Looking around, it seems like folks running Dialyzer against Elixir code are using some "dialyxer" thing.
You talk about circular dependencies causing minor compilation troubles, so it doesn't sound like you're talking about types defined in terms of each other. I might be unaware of something important, given that I've never had the opportunity to do Erlang professionally [0]... but aren't the only "dependencies" of BEAM files the exported functions they call in other modules? If I'm not wrong about that, then what happens when you run Dialyzer against BEAM files compiled from Elixir that has circular dependencies? Do its reports become more reliable, or does the reliability of those reports become irrelevant because the transformations the Elixir build system makes to your code make the structure of the BEAM code difficult to trace back to the Elixir source code?
[0] ...and have written nearly zero Elixir in any context...
I still use the Typespec syntax for its documentation benefits, and for catching "dumb" bugs, but as the Elixir compiler has improved I have found Dialyzer to be less relevant as the compiler usually catches things before Dialyzer would as it's not built into the compiler and isn't able to be.
Once compiled, it boils down to BEAM files that Dialyzer can understand, yes. And the [Dialyxir](https://dialyxir.hexdocs.pm) wrapper helps translating error messages in Elixir. But, there is a significant limitation compared to plain Erlang: Elixir protocols (which are quite used in core parts of the language) are not an Erlang construct, so Dialyzer will be clueless about them, just accepting any term. Enum.map(nil, & &foo/1) or to_string(%{}) will be invisible to it.
As for how the problem manifests: even obvious contract violations stops being shown (making it feel like "Dialyzer is useless") but the second tell is very long check times (tens of seconds up to minutes).
[W]hat happens when you run Dialyzer against BEAM files compiled from Elixir that has circular dependencies? Do its reports become more reliable, or does the reliability of those reports become irrelevant because the transformations the Elixir build system makes to your code make the structure of the BEAM code difficult to trace back to the Elixir source code?Of course people used to write server software in compiled languages feel the need for them because any runtime bug means downtime, but in BEAM land you'd have to work very, very hard to see your application crash in the classic sense, causing downtime and gnashing of teeth. And Elixir is strong typed enough never to cause the type of bugs you see in Javascript land, for example (i.e. a string is a string, not a number in some conditions)
That said, I'm perfectly happy for José and team to work on this niche feature, because for me, the language is pretty much done and all the improvements are on the OTP and library side rather than Elixir itself.
I don't have your level of experience with the language, but I have a personal project written in Elixir, and I do not feel very confident about parts of it that don't have complete test coverage, due to the lack of static typing.
I'm talking about things like: Is this pattern match exhaustive or is there a possible permutation I forgot / specified wrongly, which may then cause a match error at runtime, breaking a particular feature? (of course not bringing down the whole app due to OTP!); or if I change some keys in a map / struct in refactoring, did I forget to change them somewhere else in the application, introducing another error that is only caught at runtime?
Both of these have happened to me, I can even give you examples from code that is not my own – for my project I use a snapshot testing library by an experienced Elixir developer, and while using it I encountered two runtime crashes due to data being in the wrong shape and failing a (function clause) pattern match:
https://github.com/zachallaun/mneme/issues/85
https://github.com/zachallaun/mneme/issues/105
Proper static typing would make it very hard to write bugs like this. In Gleam for example, the compiler checks the exhaustiveness of your pattern matches against the type of the data you're matching against, and forces you to handle all possible values.
I keep hearing that but I don't think it's been true in many years? Whether it's Go, Java, C#, Rust... a runtime bug will only fail the request, not the whole server.
FWIW, the main reason I like types isn't for the compile-time guarantees (although they're certainly nice). It's for documenting what are the data types I'm working with rather than having to guess them from the code, it's for knowing that something is a square hole therefore I should put a square piece in.
But that's good! Indeed that was the most needed!
& magnificently executed - that's the craziest part - takes away nothing. The compiler is faster!! It's awe inspiring to say the least, what Jose did and still does.
This probably controversial, but personally I consider untyped languages as technical debts that need to be fixed sooner or later, and the OP article is partly addressing this very issue.
Rewriting critical software infrastructure (infostructure) to more reliable typed languages happened to most of the Ruby on Rails (RoR) software unicorn stacks for examples Twitter, Airbnb and Shopify to name a few [1],[2],[3].
The main reason provided for these migration is transitioning away from monolith architecture, but almost all of the new programming languages being used are typed thus make it obvious that the untyped languages are not performant and difficult to scale even by changing the architecture.
[1] Why did Twitter move away from Ruby on Rails?
https://www.quora.com/Why-did-Twitter-move-away-from-Ruby-on...
[2] How Airbnb Scaled by Moving Away From a Rails Monolith:
https://www.reddit.com/r/programming/comments/1756q7z/how_ai...
[3] Is Shopify shifting away from Rails?
Author here.
Type systems restrict which programs can be expressed and increasing expressiveness often requires increasing type-system complexity (which, speaking from experience, both humans and agents will struggle with). Plus they are not the only mechanism to assert correctness (they only validate a subset of your program correctness and do not replace tests) and you are still on your own when it comes to actually recovering from unexpected errors (something Erlang/Elixir were designed for).
I'd say there are two flip sides to your question:
1. Given types do not replace tests, if you can use AI to automate full test coverage, are there actual benefits in static typing for coding agents? The downside of tests for humans is that we suck at writing them (but guided agents can do better) and they can take time to run (which agents do not care)
2. Do we actually have any data or evaluations that show which typing discipline is better for agents? The only benchmark I am aware of [AutoCodeBenchmark] has Elixir come first (dynamic) and C# as second (static), so it doesn't answer the question. There are other benchmarks that show dynamic languages require fewer tokens to solve problems (but that's not a metric I particularly care about)
My gut feeling is that local structure, documentation, quality and quantity in the training data, etc are likely to play a more important role than typing for coding agents. I'd also love to measure how agents perform on specific domains. If you are writing concurrent software, how does Elixir/Java/Rust/Go compare? But without data, it's hard to say.
[AutoCodeBenchmark]: https://github.com/Tencent-Hunyuan/AutoCodeBenchmark
Full test coverage doesn’t tell you if the tests behave correctly. So you could prompt an AI agent to write 100% test coverage where those tests could be exercising all code paths yet contribute 0% to the story of what the code does. You need human understanding of what the desired contract is that the tests check.
Imagine a contract lawyer who blindly signs any contract that they are given: they aren’t doing their job. They ought to have an idea in mind of what their client’s goals and limits are so they can determine if a given contract fulfils those needs.
Types are a declarative contract, so they can be a lighter yet more limited way to enforce a contract. The compiler can verify if all the declared types across the program agree with each other. This is especially helpful with refactoring, such as ensuring the adding a field has been rolled out everywhere.
Types aren’t to be just checked by the compiler, but checked by the human authors too. That’s why explicit type signatures are valuable, especially if they are kept intelligible. They encode the different variations in state and possible branching on that state. So you can whittle your types down as a way of whittling the solution down to be more focused. The problem in your head is reflected in the types, and any simplifications in the types then simplify the problem in your head, and any tests derived from that understanding.
Devs have very strong opinions about dynamically typed programming languages. But reasons such as "exploratory programming", "expressiveness", "taste" that makes them feel good to program in for humans does not matter for agents. Agents don't care that the language "limits them" and prevents them from expressing the code in a succint way because it would not type check.
On expressiveness, people often frame it as a dynamic-language goal, but a large portion of type system research is precisely about making type systems more expressive so they can describe a wider range of programs and invariants. This is clearly something both camps value. I suppose another interesting benchmark could be: how do coding agents perform across languages with different degrees of type-system expressiveness?
We may directionally agree, but it is hard to draw conclusions without measurements. Overall, I'd say this is much more of an open question than people give it credit for.
This articulates a lot of my own thinking wrt type systems, speaking as a downstream user without a lot of exposure to prog language theory, and I wish this debate were more often framed in these terms.
Another reply to this comment hinted that it might be more about giving LLMs feedback loops and that to me also seems like a more likely mechanism.
I'm not an elixir user but I've watched it from a distance over the years – thank you for your efforts and your experimentation.
I used to hold similar opinion but D language, and this article by Patrick Li (HN JITX co-founder) who's the original author of little known but very powerful language Stanza changed my mind [1],[2].
He argued that Ruby has enabled a very expressive language that enabled RoR, and when it was originally written other languages are less capable, and accordingly the proof is in the pudding.
In his new language Stanza for his PhD thesis he has designed an optional typed system supporting both typed and untyped, it seems very similar in concept to the OP article that you've written on Elixir. Groovy also deserved a special mention, and the pudding is Grails.
Interestingly both Elixir and Stanza have GC, but Stanza also support non-GC namely LoStanza in which Stanza GC is written.
Interestingly, D language pioneered this combination both GC (by default) and non-GC more seamlessly, even before Stanza.
In addition to Ruby, these four languages namely Elixir, Groovy, Stanza and D all have similar to or better expressive power than Ruby. Notably both Stanza and D are compiled languages. Above all D is an anomaly in a good way since it's a fully type programming language. Kudos to Walter and the team for giving birth to a highly expressive fully typed modern language, very fast in compilation and runtime, truly one of a kind [3].
Regarding the issue of comparatively smaller corpus for these languages as mentioned by others, I think the new self-distillation technique for LLM and code generation as proposed by Apple, MIT-ETH and UCLA can overcome this limitation [4].
[1] “Stop Designing Languages. Write Libraries Instead” (2016) (278 comments):
https://news.ycombinator.com/item?id=46525640
[2] Stanza: People:
https://lbstanza.org/people.html
[3] Origins of the D programming language:
https://dl.acm.org/doi/10.1145/3386323
[4] Embarrassingly simple self-distillation improves code generation (201 comments):
I vaguely remember that when Groovy became more typed (statically typed that is. I believe you could always put the types in but they were not checked.) there was a theory that it kind of hurt possible uptake of the language.
The reason being is that people felt well if we are adding types and a project is requiring it why don't we just use: Java, Scala, Kotlin etc. Like did Java getting more features or Kotlin coming really hurt Groovy or just that it became more of a typed language.
An analog (typed language stealing users) could happen to Elixer but I'm not really sure which language it would be.
> I think the new self-distillation technique for LLM and code generation as proposed by Apple
Speaking of Apple and eventual typing Dylan was an amazing language that just never got traction. Open Dylan still exists but few know about it. Its eventual typing is unique because Dylan does CLOS-like multimethod dispatch instead of pattern matching.
Not sure it is much of a success. Groovy gets unreadable very fast, and the editor won’t help you. Gradle moved to Kotlin, and it’s 10x better in readability and maintainability.
I am actually writing a paper on this right now so nothing I can point you to yet but yes. LLMs are better (produce working code in fewer attempts controlling for the relative size of training corpus) when using type systems with inference and global unification. It is largely about the quality of the error feedback channel so languages with very good compiler errors (accurate, localized, include the correction with the failure) can close a lot of ground.
But inference + sound type system gives you a constraint propagation that genuinely restricts the ability of the LLM to get into trouble. Type systems that require annotation give up most of the benefit, since the annotations are themselves surface area for LLM mistakes. Unification also puts heavy limits on the expressiveness of the language which is a confounder and may actually be a big part of the benefit too.
Everyone has been on the "the training data is better" thing but I actually don't think so. All of the languages that people report as being better because of good training data actually have fairly restrictive type systems. Elixir is an exception, but it has exceptionally good error messages! And also, along with erlang, pretty unique runtime semantics that may contribute but that's outside my domain I'm on type systems. Debunking the training quality thing is not what I'm working on but I have deep suspicions about that common wisdom.
That is something I have found very effective in F#, that I model the domain with types, I know what the type signatures of the functions I need are, and the LLM does the work of actually implementing those functions.
Here is a concrete example:
I have been playing around with a program to assist me with projects I make at home on my hobby-grade CNC router, which does not have an automatic toolchanger. I use a mix of Vectric VCarve and some older handwritten programs to generate GCode files. I end up with a USB drive with maybe 6 to 12 GCode files on it and a model in my head of "to make this product, I start with a board here, gotta install this square nose end mill and zero on this corner of the board, run files A and B. Then install a ball nose end mill and run file C. Then flip the board over lengthwise, switch to a smaller square nose end mill, zero here, run file D. etc. etc."
Although I try to name the GCode files in a self documenting way like 01_TopSide_25square.ngc, if I come back in 1 year and want to make the same thing again, I pretty much always have to open VCarve and eyeball what the hell all the files did and confirm where to zero, what size board to use, etc. So I'm making a tool where I can define those human-operator steps that go with the G-Code files, save it as a "project file", preview in 3d what each step will look like, and export to a printable PDF with screenshots and step-by-step instructions. Hopefully this will reduce the amount of rot that these projects suffer and the cognitive overhead of picking up an old one.
Modeling the steps as F# types was the very first step, like (small excerpt):
type WorkpiecePlacement =
{ Id : WorkpieceId
/// Corner of the workpiece we'll attach to the machine.
WorkpieceCorner : WorkpieceSpace.Corner3D
/// Point in machine-space we'll anchor this corner to.
MachinePoint : MachineSpace.Point
/// Which face of the workpiece is on top.
FaceUp : WorkpieceSpace.Face
/// Rotation around the up-axis.
Yaw : WorkpieceSpace.Yaw
}
type OperationType =
| PlaceWorkpiece of placement : Operation.WorkpiecePlacement
| InstallTool of id : ToolId * slot : int option
| ZeroAt of point : MachineSpace.Point
| RunGCode of source : GCode.Source
| RemoveWorkpiece of id : WorkpieceId
For the GCode simulator I needed a parser for GCode files, which produces a type with 1:1 equivalence to the GCode instruction set: type GCodeInstruction =
// --- Motion ---
| G0_RapidMove of axisMoves : (Axis * float<gcodeunit>) array
| G1_Move of feedRate : float<gcodeunit/minute> option * axisMoves : (Axis * float<gcodeunit>) array
| G2_ClockwiseArc of ArcParams
| G3_CounterClockwiseArc of ArcParams
| G4_Dwell of seconds : double
// --- Plane selection ---
| G17_SelectXYPlane
| G18_SelectXZPlane
| G19_SelectYZPlane
// --- Unit selection ---
| G20_Inches
| G21_Millimeters
// --- Distance mode ---
| G90_AbsoluteDistance
| G91_RelativeDistance
// ... etc truncated, more instructions in real code
But my tool supports doing transforms on toolpaths, like rotating 90 degrees or offsetting so I can easily define that I want to make tiling copies of the same project.
To implement those transforms straight up as GCodeInstruction[] -> GCodeInstruction[] is a bad call. GCode is very stateful and lets you switch units, relative vs. absolute coordinate spaces, etc. in instructions. That makes the transform awkward and tricky to write.So I have a ToolPath type that makes the transforms clean. It normalizes the many ways of expressing the same toolpath in GCode to a single representation with all absolute coordinates in metric units.
type ToolPathInstruction =
| Rapid of From : Point * To : Point
| Linear of From : Point * To : Point * Feed : FeedRate
| Arc of
From : Point *
To : Point *
Center : Point *
Plane : Plane *
Direction : ArcDirection *
Feed : FeedRate
| ... etc truncated
That is the appropriate level for the transforms like offset, rotate, scale, etc. to operate on.Yet there is still ANOTHER level of toolpath-related operations that deserves its own type. When I'm doing simulation of material removal to check for crashes, or rendering the toolpath in 3d, I don't want to deal with arcs! The rendering/simulation is inherently an approximation. It will break down each arc into line segments. So sim code and rendering code shouldn't take a toolpath, it should take basically a line segment list, or in other words...
type ApproxMove =
{ From : Vector3
To : Vector3
FeedRate : double<m/minute>
IsRapid : bool
}
type ToolPathApproximation =
{ StartPosition : Vector3
Moves : ApproxMove[]
}
Having defined all these types it's clear that I need operations like: parse: string -> GCode
serialize : GCode -> string
normalizeToToolPath : GCode -> ToolPath
denormalizeToGCode : ToolPath -> GCode
offset : Vector3 -> ToolPath -> ToolPath
rotate90 : ToolPath -> ToolPath
scale : Vector3 -> ToolPath -> ToolPath
approximate : ToolPath -> ToolPathApproximation
simulate : ToolPathApproximation -> MachineState -> MachineState
renderToolPathWireframe : ToolPathApproximation -> VBO
renderMachineState : MachineState -> VBO
And so on. An LLM is absolutely awesome at one-shotting the implementations.I would find it quite frustrating trying to model the same domain without any types, either having all methods working on a single toolpathy data structure that's not really the right fit for any of the places it's used, or having them work on multiple data structures without any clear delineation of which layer is expecting which toolpathy-thing that are all subtly but importantly different.
People without experience in dynamic languages tend to overestimate the number of bugs their type system is saving them from. It’s pretty rare that I run into a bug in production that a type system would have caught.
They also overstate how much types help their AI agents write code. I haven’t seen AI write a type related bug in years at this point.
I work with typescript on the front end, and my experience is totally different there. AI is constantly introducing type errors, but only because the original type wasn’t declared properly. Agents waste a ton of time and tokens appeasing typescript. Ruby and Elixir are very token efficient in comparison.
That said, now that I am not writing code by hand anymore, I am considering switching to something like Go. Mainly so I can run my side projects on smaller machines
Wow, how different our experiences are. In Javascript/Typescript land, so so many bugs are null/undefined-related and really should have been caught at type level.
In fact, I'd say (without actually measuring it) that _most_ bugs I've ran into in Typescript are due to someone having bypassed the typing (casting, ts-ignore...), or a type mismatch at IO boundary.
I'd love to evidence what I'm saying with specific numbers since this kind of discussion would benefit from being as objective as possible. Sadly I don't have them. But I still believe what I'm saying and I have a few guesses about some of the causes:
1. Immutable data - so, so many bugs are caused by data mutating out from under you in subtle ways. If you write `x = 1` in your Elixir function, nothing can change the value of `x` except an explicit rebinding. You can then write e.g. `y = f(x)` and know `x` remains unchanged after. Note: this is also true even if the variable is a composite type. `my_struct = blah()` will remain the same in it's entirety no matter what you do with `my_struct`. This is different than in JS where e.g. you can change the contents of an object even if it's declared `const`.
2. Assertive style - the Elixir community favors writing things in an "assertive" fashion [1]. Briefly, this a way of writing code that will fail the moment an assumption is broken rather than letting the issue propagate.
3. Pattern matching (somewhat like destructuring in JS) - Elixir code actually ends up feeling "typed" with pattern matching. E.g. `%Time{} = today = Date.utc_today()` will attempt to bind `today` to the result of `Date.utc_today()` and will raise a `MatchError` when the result, a `%Date{}` struct, fails to be a `%Time{}` struct. Or `[a, b] = [1, 2, 3]` will raise a `MatchError` because `[1, 2, 3]` isn't a list of length exactly 2. You can use pattern matching to write very assertive code quite tersely.
These reasons are all local properties of code. But when all its parts are written in this way, a program as a whole gains a level of correctness that's hard to achieve in a dynamically typed language without them.
Also these reasons aren't exhaustive, but they're top of mind when thinking about this topic.
[1]: https://dashbit.co/blog/writing-assertive-code-with-elixir
Well yes, surely because you’re not designing your system around the type system. You need to architect your project to lean heavily on types, pattern matching, etc to actually gain the benefits. If you move a JS project to TS and just rename the files, yeah there’s going to be no difference, you must reengineer the entire codebase to leverage the type system.
Personally, after moving to TS I’ve been completely sold on types and am currently planning to migrate my app to F# so I can gain even more benefit.
Typescript is very verbose thus it cannot compete with much denser languages on token efficiency.
By the way, the biggest reason many love statically typed languages, especially those that are quite expressive like TypeScript is for the domain and data modelling. Makes it easier to reason about the program and to refactor.
Ruby's runtime in the early 2000's compared poorly against the JVM or the BEAM. People used Ruby then and now because it worked well to get products to market quickly. Even after a ton of investment in Ruby's implementation, the JVM and the BEAM are still better able to handle the types of high-traffic, high-concurrency workloads those companies serve, which makes them relevant to mature, high-scale companies.
Tellingly, there are dynamic language implementations that are performance-competitive with static language implementations, like Javascript's V8/Bun/Deno, Lua's LuaJIT, and Common Lisp's SBCL (among others, this is not an exclusive list).
The runtime performance and the language are deeply linked. None of the dynamically typed runtimes you mention are actually performance competitive with JVM languages.
Random example benchmark: https://madnight.github.io/benchmarksgame/lisp.html
https://benchmarksgame-team.pages.debian.net/benchmarksgame/...
For example, typescript is a fantastic language for marshalling data and UI state since it uses substructural typing instead of nominal typing. Libraries like kysely / other ORM libraries are great examples too and easy to use, whereas in fully typed languages like Rust you would end up having to use a macro library like sqlx or having to define structs for each of your types (which would increase compile time & size)
This depends entirely on context. In the Benjamin C. Pierce school of thought (a common choice in programming langauges research; see his book Types and Programming Languages, 2002), "typed" means what we typically call statically typed, i.e., the language employs a static analysis to prevent the compilation/execution of (some subset of) faulty programs. Meanwhile, languages that are commonly called "dynamically typed" are, in this school of thought, not typed (or "untyped"). (TAPL provides a more rigorous definition, but it's in the other room and I am lazy.)
They naturally use types for compilation, but the type system is trusted to forbid some invalid states. Underneath it’s all bits and bytes.
Even in safe languages you need deserializers/parsers/validators.
Typescript actually ends up having more checks because it runs Javascript underneath (although some might argue those barely count).
For runtime types I've leaned on Zod or Effect schema,which can also generate static types for you.
without any evidence, i claim the corpus might have higher quality variable names and conventions that are "human crutches" around not having types.
LLM knowledge in your non public codebase must be strictly local, and so checking on details and identities of types incurs a cost for the LLM to go fetch that info. if the LLM can "just know" (guess with very high confidence) then thats better for the LLM.
> non-typed languages has more traning data
as per anthropic "poisoning llms with 250 examples" finding, i suspect that corpus size does not really matter that much for any language that is reasonably well used.
Part of the point of types is enforcing more of the contract at various code boundaries (function, module, etc), and that enforcement is specifically so that you don't have to keep the whole codebase in your head / context window in order to be able to work on it.
That surprises me, but everyone's experiences are different. I've been in the statically typed language space for so long and enjoyed it so much, I find it pretty irritating to go back to Python (my long-ago favorite) but many people are in the exact opposite frame of mind. I'm curious: what kinds of errors do you classify as a type-based error? I think that varies from person to person.
For example, null references. A C programmer would say dereferencing a null is not a type-based error, because it's not feasible to encode non-nullable pointers in the C type system. A Haskell programmer would say it is a type-based error because Haskell makes it difficult not to encode this in the type system, you really have to go out of your way to create a runtime null dereference error.
A C# or TypeScript programmer could answer differently depending on who you ask, because both of those languages make it possible to leverage the typechecker to prevent null-deref at compile time, but neither one makes it required (you can turn those checks off or make them warnings if you like), so it depends on the programmer's build settings and how much typechecking they personally have chosen to use.
As someone who works exclusively in typed languages for formal methods, what is it you find lacking about modern Python + PyLance? IMO there's still a tiny verbosity issue, and there's no real replacement for fancier polymorphism or (G)ADTs, but I'm very satisfied with it for most things. In particular, null checks are trivial.
However, in principle any dynamically typed language can be tolerable to me if it can be turned into a statically typed language ;)
But I think I'd still prefer the ergonomics of a language designed that way from the start vs having bolt-ons. My favorite language for the past several years has been F# and I think ML-family languages in general strike a great balance of being able to write terse code when you want to, and being able to model a domain really well with types when you want to.
A couple of years ago I did some contract work for a client who used Javascript.
I did some basic smoke testing to understand the state of the app and I was able to get lots of fun type errors on the server and client at runtime just by QAing the damn thing.
Typing probably makes sense where memory-correctness needs to be enforced (e.g. Rust), and inferring those semantics require a much wider context. But memory-correctness isn't really something that afflicts BEAM languages.
That is a very good thing to help us reason about the program, we have invariants we know must hold true if the program does not stop in a type-error.
If you're statically typed you can remove the actual check from the binary. They are therefore also a performance thing.
I don't use Rails, so don't have any skin in the game. But who cares if you have to do a re-write once you get to that size?
As orgs grow, the only way to maintain velocity is to reduce mental context. Humans have to reason about their systems.
In the half a dozen engineering orgs I have worked, each and every one became a quagmire of slow eng velocity and saw increased velocity and less bugs as they reduced context needed by teams. Separation of concerns, allowing individual services that run independently, more and better tests and observability, and, yes, better typing.
Lots buy into the view "the old system got us here and now we can afford to rewrite and do things 'right'." The real cost is, literally, moths to years of dev efforts to unwind tangled concerns. Million to tens of millions in developer salaries that are going towards keeping the ship afloat as the hull is changed out. The opportunity cost is truly mind blowing.
To avoid that cost: keep concerns separate, define data domains, and use a language that allows you to keep logic localized. If you have to jump files to understand your incoming parameters, you're gonna have a bad time when things no longer fit in your head, and esp. when new to the code as a new hire.
Elixir, I still had to know my whole call chain to know what I could do with my incoming parameters. The more call sites, the more mental context. I choose static types because I can KNOW what my function is receiving locally: it is the type signature.
I would like to validate my experience against other static typed languages like c#; so far, I have seen wins at every org that switched from dynamic languages to Go. Go seems to get a lot right for helping eng orgs move faster.
The real truth is that language preference (typed or dynamic) are more of a fashion choice in most companies where I was present than a pure technical consideration.
if you build your product by accumulating technical debt without any focus and effort toward simplicity and trying to make it do anything then the solution after many years is rewriting. But if you have the same culture and keep the same customers you will be in the sample place where you have started but now having different category of problems (eg network latency vs N+1s).
Maybe this is the "way of the startup" but lets not pretend that types can fix culture, engineering practices or product vision and good customer management.
but the call chain doesn't have to be long, i.e. it could be just 2 or 3 places; that fits inside my head. less is more
Elixir is amazing when the system fits in your head.
Instagram (and Threads) is still using Django, which is even slower than Rails. Once you get to unicorn scale, your app is going to bespoke, with some microservices, and super custom stuff. If you can go faster in a gradually typed language, that can be a very good reason to choose one.
> untyped languages are not performant
Typing generally slows down languages, not speed them up because of all the additional checks that must be done. The dynamic stuff is part of what slows down languages like Python and makes them tricky to optimize.
Source? You seem to be talking about compile-time versus runtime, and I've not even heard of compile times being significantly slowed by type checking.
> The dynamic stuff is part of what slows down languages like Python and makes them tricky to optimize.
That seems to harm rather than help your previous claim. In untyped languages, in principle every object has to be treated as dynamic.
Look at Swift. But yeah, Swift is the only language I've ever heard having compile time issues because of the type checking.
Yes 100%! I was talking runtime in reference to Ruby and later Python.
> That seems to harm rather than help your previous claim. In untyped languages, in principle every object has to be treated as dynamic.
It is rather confusing and even counterintuitive, but being dynamic does not mean a language must also be untyped. For example, Python is both strongly typed and dynamically typed at once. [1] It's objects have a definitive type, but you can swap out objects of any type out at any time (a=1 ... a="foo") using the same variable. That makes optimization rather tricky as you can imagine.
1 - https://wiki.python.org/moin/Why%20is%20Python%20a%20dynamic...
https://xlii.space/eng/from-rust-to-ruby/
The thesis that you're making is biased. Huge tech corps can move away from Rails, but it's similar to argument of "why the most successful people in the world don't drive Toyotas". Which is true, but it doesn't mean people should stop using Toyotas and buy Koenigsegg instead.
Typed languages have consequences. Some designs are either non-ergonomic or impossible. Rust: if you want to have a swappable adapter you're in Box<dyn> world. Many apps don't have to live in Box<dyn> at all but they need to test which is the sole reason to change architecture and wrap in boilerplate.
None of these reasons matter if you're multimillion tech corporation with unlimited resources.
But these are very important reasons to consider when you have small-medium sized team and cannot afford to fight language.
The only thing propping them up seems to be loyalty for the most part.
That or it’s a evangelist from the church of AI speaking based on faith rather than reason.
Or some combination of the two.
LLMs are good at current programming languages because they had lots of data to train on.
I'm even less prone to use them with AI.
Most gradual type systems insert coercions when values cross the types/untyped boundary (checking every element of a list, wrapping values in typed proxies, etc) but Elixir's team published a "strong arrows" result specifically to achieve soundness without those runtime checks. The bytecode the compiler emits is semantically identical to untyped code.
that said, I'm a fan
I think that's part of the reason that LLMs do so well with it, despite its relative lack of popularity.
They can all write serviceable Elixir. Opus is my preferred one, but they do decently well enough for typical coding tasks.
Input > Enumerable.Map(Input, type-speccd functionA) > Enumerable.Map(Input, type-speccd functionB)
Here's just one very simple example, there are many more. I've checked all the strict mode options and this appears to still "typecheck".
var x: {a: number} = {a: 1};
var y: {a: number|string} = x;
y.a = 'FAIL';
var n: number = x.a; // not actually a number
Source: https://www.typescriptlang.org/play/?noUncheckedIndexedAcces...1. TypeScript doesn't aim to have a sound type system. i.e. there may be things the type system accepts that are actually unsafe.
2. this is more of an issue with mutation. If those properties were marked `readonly`, then the assignment of y.a wouldn't work at all. You can also encapsulate mutation behind functions with your intended types.
I tend to write TypeScript in a "functional" or "immutable" way, and in this case, most soundness issues come from things like array index access, which can't really be solved without dependent types anyway.
With that said, TypeScript still gets one quite far *despite* soundness not being a goal of the type system. The problem is that writing imperative, mutable code will make you go through (intentionally!) unsound covariance of types. Similar issues exist for code with side effects, since TypeScript has no way to encode effects in the type system. This is why some language communities settle on ideas like "functional core, imperative shell", where the ultimate goal is absolute minimum amount of code involved in side effects and mutation, while everything else is designed to be easy to test (and, ideally, expressible with a sound subset of your type system).
It's actually a very powerful tool when used thoughtfully. Although it wasn't the first structurally typed language I tried, it's the one that made me fall in love with structural type systems
It Catches: Mismatched function arguments, missing object properties, and typos in variable names.
It Misses: Invalid JSON from an API, unexpected database outputs, and bad user input.
I would also just like to point out that the "It Misses" your robot pointed out aren't actually flaws with TypeScript but flaws with JavaScript.
I used to be a bit of a pragmatist when it comes to strict mode, but over the years that has subsided, nowadays I think it is plainly obvious that all Typescript programs should use strict mode unless there's a damn good reason. And I'm not sure there are any legitimate damn good reasons.
True there is no ability to forbid an explicit-any type declaration, though.
The real problem with Python is the inexpressiveness of its type system and the mess of typed dicts, dataclasses and pydantic classes.
TypeScript may fail narrowing here and there or require a superfluous assert, but usually writing properly typed code, especially with zod, is the path of least resistance.
You probably have the same logical type duplicated in 3+ different places (at least partially), including inline casts using type literals like "maybeCat as { meow(): void }"
Elixir is always been sort of a "typed dynamic language" due to how baked in pattern matching is. Any good Elixir developer has always been thinking about types anyway, it's almost impossible not to.
I’ve toyed around with it a handful of times and I really like it. I like the clojure-ey immutability and threading operators and such. And of course I’ve heard so much about the magic of the BEAM and the phoenix framework. But between typescript and clojure I’ve never felt like I needed anything else.
But if the type system is pretty good, that’s a huge plus over clojure in my book.
I don’t think JavaScript’s syntax was ever designed with the idea that TypeScript would one day exist. Yet somehow it feels like it left the perfect open spaces for TS to later occupy.
Andd boy, a REAL type system is just something i won't ever again compromise upon. I mean yeah I did many years of Ruby/Rails and loved it back then, and Elixir in that regards at least on surface felt strictly better (sweet pattern matching, pipes, ...) but just SO MUCH CODE is written either at runtime or in loads of tests that essentially make up for the lack of a compiler guarantee about type errors i cannot unsee it anymore. Rust is way better here for example for sure, Trait system and all, but here the compile time tax is very real even after fiddling with optimal crate splits. Plus _sometimes_ a bit of simple mutable code just hits home in a few lines instead of often slower pure FP equivalents.
Happy to see that Elixir finally after years in the making is arriving somewhere, but I essentially left the ecosystem now since I really do either TDD (Type driven Development) now or quick solutions with node/go when quality isn't the concern... and now I discover OCaml (with Effects based multicore now) and yes the syntax is _a bit_ alien but damn it checks all boxes of all techstacks I ever wanted. I can write nearly Elixir style code, pattern match pipes and all, I can write (nobody does but I could) failry powerful OOP stuff, compile instantly, in a statically linked binary, with true parallelism, and a type system that is amazing (don't get me started about module functors). Beam is a impressive feat of engineering, but its also moving like molasses and deployment is nontrivial and quite cumbersome to operate (at least people need quite a lot of learning curves until theyre comfortable with this powerful beast). And then there is OCaml. And the tradeoff here is on the human side, nearly no one knows it, learning curve is high, so statistically no team would pick it in most businesses or has experience with it, and that specific situation is personally for me irrelevant now as a solo builder in an LLM age.
Lets see how good this becomes at some point, I am watching and would have loved to have this at least gradual typing available years ago!
I love the fact that I can upgrade my elixir version and the compiler finds a bunch of free bugs. The last several releases have been like this, and basically no breaking changes.
I would be thankful for pointing at any other language that reliably and safely adds great features and is already convenient to use. I jumped from mastering Go to learning advanced C#, because Go stopped with adding great things :(
I only say it’s not “already convenient to use” because I heard tons of complaints about the dev environment - mostly that there’s no debugger, no official package manager, etc. But they are working on ‘dune’, and just like the language itself, I got the impression that the dune developers were being conscious to “add great features reliably and safely”. So overall I thought it was a great language/ecosystem, ymmv though.
let fac =
let rec fac' acc = function
| 0 -> acc
| n -> fac' (n * acc) (n - 1)
in
fac' 1
let seven =
let four = 4 and three = 3 in
four + three
https://ideone.com/HpTrI4It is really excellent!