That's interesting to hear. I started out with a formal CS education learning Java & C in school. I've found that traditional CS education doesn't really take this approach. A lot of what I was exposed to was very OOP-heavy practices that emphasized data modeling via class hierarchy. To me, the expressiveness of the Typescript system (being able to do things like sum types or branded types) is what unlocked a lot of potential despite not being a compiled language.
After using languages like Scala, Java(I use 17) feels like a joke in terms of type system expressiveness and ability to use functional patterns. I currently have to switch between the two and the kindest thing I can say about Java is it’s getting better(very slowly).
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.
A lot of the runtime fiddling is indeed a plague (the limited reflection is one of my favorite parts of Go, it means I can trust function call boundaries FAR more), but Java does do some nice things. E.g. I wish every language had as powerful of a compile time system as Java does - annotation processors and compile-time byte-code weaving enable magic "best of all worlds" stuff like Lombok, and it integrates with IDEs transparently. And hprof -> MAT is absolutely incredible compared to the memory-profiling capabilities of most languages.
The debugging and profiling features are definitely better than most, but other languages running on the JVM benefit from that too.
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.
a lot of the really functional stuff has happened from 17 onwards (though probably present as preview features since 17 perhaps?)
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).