I'm working on a project where I need to prove that a file in a git repo is append only, ie all changes to the file only added lines. The only way I can think of is looking at the git history of the file, but would there be a faster way using Merkel trees somehow?
I also preferred dynamic typing, until my complex rails app grew to the point I didn't dare to do any refactoring.
But I didn't switch opinion until I discovered ML type systems, which really allow for fearless refactoring. At occasion there's some battling to satisfy the typesystem, but even with that I'm more productive once the app grows in complexity.
I thought I'd share my experience, not trying to convince anyone ; - )
I have a F# background, and thought to have read that some constructs I learned to appreciate are not available in Gleam (the one I can think of right now is currying, but I thought there were others).
The issue isn't that OTP isn't a priority for Gleam, but rather that it doesn't work with the static typing Gleam is implementing. This is why they've had to reimplement their own OTP functionality in gleam_otp. Even then, gleam_otp has some limitations, like being unable to support all of OTP's messages, named processes, etc. gleam_otp is also considered experimental at this point.
Having Erlang-style OTP support (for the most part) is very doable, I've written my own OTP layer instead of the pretty shoddy stuff Gleam ships with. It's not really that challenging of a problem and you can get stuff like typed processes (`Pid(message_type)`, i.e. we can only send `message_type` messages to this process), etc. out of it very easily.
This idea that static typing is such a massive issue for OTP style servers and messaging is a very persistent myth, to be honest; I've created thin layers on top of OTP for both `purerl` (PureScript compiled to Erlang) and Gleam that end up with both type-safe interfaces (we can only send the right messages to the processes) and are type-safe internally (we can only write the process in a type-safe way based on its state and message types).
I wholeheartedly agree with you that gleam_otp is janky. Still, actor message passing is only part of the picture. Here are some issues that make static typing difficult in OTP:
• OTP processes communicate via the actor model by sending messages of any type. Each actor is responsible for pattern-matching the incoming message and handling it (or not) based on its type. To implement static typing, you need to know at compile time what type of message an actor can receive, what type it will send back, and how to verify this at compile time.
• OTP's GenServer behaviour uses callbacks that can return various types, depending on runtime conditions. Static typing would require that you predefine all return types for all callbacks, handle type-safe state management, and provide compile-time guarantees when handling these myriad types.
• OTP supervisors manage child processes dynamically, which could be of any type. To implement static typing, you would need to know and define the types of all supervised processes, know how they are going to interact with each other, and implement type-safe restart strategies for each type.
These and other design roadblocks may be why Gleam chose to implement primitives, like statically typed actors, instead of GenServer, GenStage, GenEvent, and other specialized OTP behaviours, full supervisor functionality, DynamicSupervisor, and OTP's Registry, Agent, Task, etc.
OTP and BEAM are Erlang and Elixir's killer features, and have been battle-tested in some of the most demanding environments for decades. I can't see the logic in ditching them or cobbling together a lesser, unproven version of them to gain something as mundane as static typing.
EDIT: I completely missed the word "actor" as the second word in my second sentence, so I added it.
I suppose I was unclear. It is OTP-style `gen_server` processes that I'm talking about.
> OTP processes communicate via the actor model by sending messages of any type. Each actor is responsible for pattern-matching the incoming message and handling it (or not) based on its type. To implement static typing, you need to know at compile time what type of message an actor can receive, what type it will send back, and how to verify this at compile time.
This is trivial, your `start` function can simply take a function that says which type of message you can receive. Better yet, you split it up in `handle_cast` (which has a well known set of valid return values, you type that as `incomingCastType -> gen_server.CastReturn`) and deal with the rest with interface functions just as you would in normal Erlang usage (i.e. `get_user_preferences(user_preference_process_pid) -> UserPreferences` at the top level of the server).
Here is an example of a process I threw together having never used Gleam before. The underlying `gen_server` library is my own as well, as well as the FFI code (Erlang code) that backs it. My point with posting this is mostly that all of the parts of the server, i.e. what you define what you define a server, are type safe in the type of way that people claim is somehow hard:
It's not nearly as big of an issue as people make it out to be; most of the expected behaviors are exactly that: `behaviour`s, and they're not nearly as dynamic as people make them seem. Gleam itself maps custom types very cleanly to tagged tuples (`ThingHere("hello")` maps to `{thing_here, <<"hello">>}`, and so on) so there is no real big issue with mapping a lot of the known and useful return types and so on.
I read the code but I'm not sure I understood all of it (I'm familiar with Elixir, not with Gleam).
For normal matters I do believe that your approach works but (start returns the pid of the server, right?) what is it going to happen if something, probably a module written in Elixir or Erlang that wants to prove a point, sends a message of an unsupported type to that pid? I don't think the compiler can prevent that. It's going to crash at runtime or have to handle the unmatched type and return a not implemented sort of error.
It's similar to static typing a JSON API, then receiving an odd message from the server or from the client, because the remote party cannot be controlled.
> [...] start returns the pid of the server, right?
Yes, `start` is the part you would stick in a supervision tree, essentially. We start the server so that it can be reached later with the interface functions.
> [...] probably a module written in Elixir or Erlang that wants to prove a point, sends a message of an unsupported type to that pid? I don't think the compiler can prevent that. It's going to crash at runtime or have to handle the unmatched type and return a not implemented sort of error.
Yes, this is already the default behavior of a `gen_server` and is fine, IMO. As a general guideline I would advise against trying to fix errors caused by type-unsafe languages; there is no productive (i.e. long-term fruitful) way to fix a fundamentally unsafe interface (Erlang/Elixir code), the best recourse you have is to write as much code you can in the safe one instead.
Erlang, in Gleam code, is essentially a layer where you put the code that does the fundamentals and then you use the foreign function interface (FFI) to tell Gleam that those functions can be called with so and so types, and it does the type checking. This means that once you travel into Erlang code all bets are off. It's really no different to saying that a certain C function can call assembly code.
Seems I've been doing something like spec driven development on my last project. I keep a spec of the solution developed, and include it in every request sent to the ai, and it yields good results in my case. I'm still the developer in charge, but I can easily hand off non subtil or general code generation. It's clearly helped me code faster, though I had to spend quite some time on the spec, which still clarified a lot of things for me too. In the end I enjoy this approach.
Working on a multisig solution for authenticated file distribution, initially targeting GitHub releases. Based on minisig and git.
I think this project is an interesting addition as a software supply chain solution, but generating interest in the project in this early stage proves difficult.
I usually ask it to build a feature based on a specification I wrote. If it is not exactly right, it is often the case that editing it myself is faster than iterating with the ai, which has sometimes put me in an infinite loop of corrections requests. Have you encountered this too?
For me I only use it as a second opinion, I got a pretty good idea of what I want and how to do it, and I can ask any input on what I have written. This gives me the best results sofar.
At that point, you might as well write it yourself. Instead of writing 300 lines of code, you are writing 300 lines of prompts. What benefit would you get?
Its not. "Add this table, write the dto" takes 10 seconds to do. It would take me few mins probably assuming Im familiar with the language and much longer if Im not.
But its a lot better than that.
"Write this table. from here store it into table. Write endpoint to return all from the table"
I also had good luck with stuff like "scrape this page, collect x and y, download link pointed at y, store in this directory".
This only happens if you want it to one-shot stuff, or if you fall under the false belief that "it is so close, we just need to correct these three things!".
Yes I have encountered it. Narrowing focus and putting constraints and guiding it closer made the LLM agent much better at producing what I need.
It boils down to me not writing the code really. Using LLMs actually sharpened my architectural and software design skills. Made me think harder and deeper at an earlier stage.
If you prefer to generate a static site, take a look at https://soupault.app/
It generates a static website from markdown or other formats. Mentioning it here because it deserves more attention than it currently gets.
The only issue with it is that Rust's aversion to half-baked code means that you can't have "partially working code" during the refactor: you either finish it or bail on it, without the possibility to have inconsistent codebase state. This is particularly annoying for exploratory code.
On the other hand, that strictness is precisely what leads people to end up with generally reasonable code.
I find a healthy dose of todo!() is excellent for this.
match foo {
(3...=5, x, BLABLABLA) => easy(x),
_ => todo!("I should actually implement this for non-trivial cases"),
}
The nice thing about todo!() is that it type checks, obviously it always diverges so the type match is trivial, but it means this compiles and, so long as we don't cause the non-trivial case to happen, it even works at runtime.
The thing is I want an equivalent to `todo!()` for the type-system. A mode of operation where if you have some compile errors, you can still run tests. Like for example, if you have
fn foo() -> impl Display {
NotDisplay::new()
}
and a test references `foo`, then it gets replaced for the purposes of the test with
So maybe a proc macro which lets you #[dummy(Clone,Eq,PartialEq)] instead of #[derive(Clone,Eq,PartialEq)] ?
Although you said "mode of operation" and I can't get behind that idea, I think the choice to just wrap overflow by default for the integer types in release builds was probably a mistake. It's good that I can turn it off, but it shouldn't have been the default.
What's the advantage of that over putting `return todo!();` at the top of `foo()`? You do have to go through and mark the places where you have further work to do, but you don't have to finish the refactor.
I'm proposing this not as a specific feature but as a general strategy: everything the compiler can conceivably recover from in a way that allows the rest of the application to run, should. Think borrow checking, for example. If it could be changed automatically to Arc<Mutex<T>> from &T, when running the test you could say not only the borrow checker error, but also point at the specific runtime case that the borrow checker protected you from if any test exercises it.
Adding `return todo!()` works well enough for some cases, but not all, because it can't confirm against impl Trait return types.
And these strategies are things that people need to be taught about, individually. I'm not saying that the current state is terrible, just that there might be things we can do to make them better.
> I'm proposing this not as a specific feature but as a general strategy: everything the compiler can conceivably recover from in a way that allows the rest of the application to run, should.
I do think that'd be useful in a variety of cases, especially for testsuites. I don't think I'd want to go as far as trying to guess how to substitute `Arc`/`Mutex`/`RwLock` for a failed borrow, but there are a few different strategies that do seem reasonably safe.
In addition to the automatic todo!() approach, there's the approach of compile-time tainting of branches of the item tree that failed to compile. If something doesn't compile, turn it into an item that when referenced makes the item referencing it also fail to compile. That would then allow any items that do compile to get tested in the testsuite.
> Adding `return todo!()` works well enough for some cases, but not all, because it can't confirm against impl Trait return types.
Not in the fully general case, but ! does implement Display, so it would work in the case you posted.
It's a tradeoff, reminds me of Go not compiling if you have an unused variable; the strictness is a feature and basically locks out sloppy / half baked code.
I personally see Rust as an ideal "second system" language, that is, you solve a business case in a more forgiving language first, then switch (parts) to Rust if the case is proven and you need the added performance / reliability.
Fsharp's type inference is great in that regard. Function types are inferred too, in contrast with rust.
I find fsharp fit for exploratory code, and its type inference is probably the enabler.