I too agree that "until you get better" isn't a good take. To err is human, and even the most experienced developers make mistakes.
That said, you don't get static typing for free. As with many things it's a trade-off: you catch some errors at compile time in exchange for working within the confines of the type system. The ultimate hope is that the time you spend fiddling with types is going to be less than the time you spend debugging type errors.
> There is nothing you can do with dynamic typing that you cannot do with a sufficiently powerful static type system - and it doesn't have to be something absurd like Haskell's. You basically just need structural typing and type inference and some type-level programming constructs.
Haskell doesn't have a complex type system for no reason; it's necessary to encompass everything it wishes to do, and even then it's not as flexible as a dynamically typed language.
For instance, how would you statically type Clojure's `assoc` function? It's not at all trivial if you want to retain the type information of the keys and values.
The problem with your counter-argument is that it hinges on a false premise: That you need or even want a function like `assoc` which is polymorphic over everything. It's an extremely overloaded function which does a lot of things at once, and in many circles and arguably in general within the realm of software design, this is considered a smell.
In practice, what you want is something that allows you to do this safely for the concrete type you're working with. If you want an abstraction that covers all of it, there are ways to achieve this in a type-safe manner, such as traits/type classes. Even in clojure, you're not working with everything at once all the time. You are working with a record, or a vector, or whatever. The fact that you can use one function for all of them is mostly just needless cleverness. In Clojure, you have to keep the type of the data you are working with in your head at all times, because even though `assoc` "just works" for many cases, that's not true in all cases. It will happily insert an integer key into a record without issue, which may or may not be waht you want. But you can also try to insert an atom key into a vector, which then crashes loudly. This is clearly an asymmetry in the abstraction.
Moreover, pointing out that Haskell cannot do what you'd want to do in this case doesn't make a lot of sense. I mentioned Haskell precisely because its type system is extremely powerful and complicated to understand for a lot of people, but still doesn't achieve the kind of flexibility we are looking for - it lacks row polymorphism.
To answer your actual question: Typing a function like that for the individual cases is bordering on trivial in a language such as typescript. For the record case, you don't even need it, because in practice, you get the correct type inference for free by just spreading one object into another.
I don't see an asymmetry in the abstraction. Both vectors and maps are associative structures - you can assign a key to a value - the only difference is that vectors have a more constrained keyspace (i.e. ordered, consecutive integers starting from zero).
But that wasn't really my point. Even if we limit `assoc` solely to maps it would still be difficult to type effectively.
For instance, suppose we have some code like:
(let [m* (assoc m :number 3)]
(:number m*))
We can see that the return type of this expression is obviously an integer, but what is the type of m*? How do we type m* such that (:number m*) can be inferred to be an integer by the compiler?
Most statically typed languages sidestep this problem: instead of using an open data structure like a map, a closed structure like a record or class is used instead, and these structures must be explicitly typed by the user.
The problem with this approach is that now every record is specific and bespoke. You lose access to all the general-purpose functions that operate on generic data structures, and as records and classes are closed, you also lose the ability to extend them.
This is the ultimate problem with static type systems: you're trading capability for safety. If you're programming within a static type system, there are options that are simply not available or feasible to use.
> I don't see an asymmetry in the abstraction. Both vectors and maps are associative structures - you can assign a key to a value - the only difference is that vectors have a more constrained keyspace (i.e. ordered, consecutive integers starting from zero).
The asymmetry lies in the fact that it's an overloaded function that's supposed to do the right things every time, but in some cases, it does what is arguably the wrong thing, silently, and in others, it refuses to do the wrong thing and fails loudly. It's better that it fails loudly, of course, but the point is that the ergonomics of the abstraction is lessened because you can't just assume it will work. You effectively have to keep the types of all the things involved in your head and/or trace them to ensure that you don't run into a crash.
> We can see that the return type of this expression is obviously an integer, but what is the type of m? How do we type m such that (:number m*) can be inferred to be an integer by the compiler?
> This is the ultimate problem with static type systems: you're trading capability for safety. If you're programming within a static type system, there are options that are simply not available or feasible to use.
This is just not true. It's true for some certain specific static type systems, but not true in general, and that brings me back to my original thesis: You just need a sufficiently capable type system with the right properties - structural/row polymorphism, ish, plus type inference. And also my Haskell point: it doesn't have to be an incredibly complicated type system that is beyond mortal ken. TypeScript is already doing this and it's arguably one of the most used programming languages on earth.
> You effectively have to keep the types of all the things involved in your head and/or trace them to ensure that you don't run into a crash.
You make this sound difficult, but in practice type errors are rare in Clojure and generally caught in the REPL or by tests, since the moment you go down a branch with a type error an exception is thrown.
Contrast this to errors caused via mutable state, which are usually far harder to track down, because the failure condition is more specific.
> This is trivial in TypeScript.
In the example you give you're omitting assoc entirely, which defeats the point. I'm using assoc as a minimal example, but the same principle applies to more complex functions, so replacing assoc with the equivalent expression doesn't tell us whether or not we can effectively type a function that deals with maps.
So lets try doing this properly. At minimum we need something like this:
type Assoc<M extends object, K extends string, V> =
Omit<M, K> & Record<K, V>;
function assoc<M extends object, K extends string, V>(
m: M, k: K, v: V): Assoc<M, K, V> {
return { ...m, [k]: v } as Assoc<M, K, V>;
}
(Note that we need to perform an explicit cast in order to inform TypeScript of the type of the key.)
However, this produces some rather messy types consisting of nested Assocs. In order to get back to something a human can read, we can use an additional Simplify type to force the type system to reduce it back down into an typed object:
type Simplify<T> = {[K in keyof T]: T[K]} & {};
type Assoc<M extends object, K extends string, V> =
Simplify<Omit<M, K> & Record<K, V>>;
function assoc<M extends object, K extends string, V>(
m: M, k: K, v: V): Assoc<M, K, V> {
return { ...m, [k]: v } as Assoc<M, K, V>;
}
(The empty `& {}` intersection forces normalization, providing a cleaner reported type.)
We're still not done, though, as if we want the same type checking that a class has, we need to ensure that a key cannot be overwritten with a value of a differing type. So we'll type the value argument as well to ensure it matches the type of an existing value within the map:
type Simplify<T> = {[K in keyof T]: T[K]} & {};
type Assoc<M extends object, K extends string, V> =
Simplify<M & Record<K, V>>;
type AssocValue<M extends object, K extends string, V> =
K extends keyof M ? (V extends M[K] ? V : never) : V;
function assoc<M extends object, K extends string, V>(
m: M, k: K, v: AssocValue<M, K, V>): Assoc<M, K, V> {
return { ...m, [k]: v } as Assoc<M, K, V>;
}
So this is possible to type in TypeScript (to its credit), but is it "trivial"? And is this type signature significantly less complex than one might find in Haskell?
> Programming language syntax scarcely matters. It does to some extent but we the programmers tend to over-romanticize it. The runtime and its properties are the much better thing to optimize for.
I'm not sure I understand this argument. Java and Clojure share a runtime, but an idiomatic Java codebase is going to have a very different architecture and design to an idiomatic Clojure codebase. Conversely, a codebase written in Go may end up looking very similar to a codebase written in Java, despite using different runtimes.
To be clear, I'm not questioning your choice of runtime or language. I'm just curious why you think that "Programming language syntax scarcely matters", as to me that seems the same as saying "How a codebase is architectured and designed scarcely matters".
I don't see how the latter follows from the former? The former is much bigger and more abstract; syntax is just one of the vehicles to try and codify it.
F.ex. if you have an universal construct of green threads / fibers then 7 PLs could express it 7 different ways, yet underneath they'd all be the same.
The programming language informs the design of the system. As I said in my earlier comment, an idiomatic Java codebase is going to be designed very differently to an idiomatic Clojure codebase, even if they both intend to solve the same problem.
Scala has no immutability encoded in its runtime either (as it's the same as Java), but yet syntactically it's immutable in practice. Will the JRE technically allow a val to be edited through some third party thread inspecting your code and messing with memory? Sure. But it's not a reasonable fear in any real world environment, where I cannot remember, in 15+ years of professional scala, a case where anything I expected to be immutable (everything) to be mutated under me. Nowadays people using in in an FP style don't even think of the physical threads, as green thread libraries are taking care of all the scheduling.
So focusing on the runtime's guarantees doesn't seem like a practicality focused argument to me.
You are citing a commendable exception (Scala) to tear down a bigger argument which is not exactly a fair discussion.
Furthermore, if you trace my comments, you'll see that I had to choose PLs years ago (12+ to be precise). Things were quite different at the time. Java might have almost caught up today; back then we couldn't even be certain `synchronized` is stable all the time. Just saying.
Scala did very well then, judging by your words. I could probably offer a loose analogy to Typescript as well; while it does compile to JS underneath, they added a stricter layer that makes programming in it more deterministic and stable. (Not the same thing because my main point was "runtime" but hey, show me a perfect analogy.)
You are free to say your last sentence. I am free to disagree. My practice has shown me that runtimes bleed into syntax almost always. Exceptions exist, sure.
But syntax must necessarily include what it's representing, no? For instance, `{:a 1}` represents an immutable map in Clojure, in the same way that `42` represents an immutable integer in Java.
I'm not sure I agree. Certainly there are differences other than syntax, but that doesn't mean syntax is irrelevant. For instance, would Clojure programmers use maps as much if there was no syntax for map literals?
Syntax determines what parts of a language are within easy reach, and therefore affects how programmers use the language. Tools that a syntax make easy are used often; tools that syntax makes hard are used infrequently. This indirectly impacts how a piece of software is designed.
This is very much what I meant in the post (hi, I'm the author :P)! CL has maps, but they're a pain to use - not just because of the syntax, but because of the relative dearth of standard library functions to work with them compared to say, lists or even vectors.
In the UK there's been a recent spate of nationalist flag flying. Given the artist and location, "blinded by nationalism" is the most likely intended meaning.
Typically you're either deploying via a container, in which case there's no more overhead than any other container deployment, or you're deploying directly to some Linux machine, in which case all you need is a JVM - hardly an arcane ritual.
cljfmt is included with both Clojure-LSP and CIDER, so if you have either installed it should work out of the box.
With LSP mode the standard `lsp-format-region` and `lsp-format-buffer` commands should work, and on the CIDER side `cider-format-defun`, `cider-format-region` and `cider-format-buffer` should also invoke cljfmt.
I'll add a note to the cljfmt README to tell people about these commands, as your experience shows that it might not be obvious to people that they likely already have access to cljfmt in Emacs as a result of using LSP or CIDER.
That said, you don't get static typing for free. As with many things it's a trade-off: you catch some errors at compile time in exchange for working within the confines of the type system. The ultimate hope is that the time you spend fiddling with types is going to be less than the time you spend debugging type errors.
> There is nothing you can do with dynamic typing that you cannot do with a sufficiently powerful static type system - and it doesn't have to be something absurd like Haskell's. You basically just need structural typing and type inference and some type-level programming constructs.
Haskell doesn't have a complex type system for no reason; it's necessary to encompass everything it wishes to do, and even then it's not as flexible as a dynamically typed language.
For instance, how would you statically type Clojure's `assoc` function? It's not at all trivial if you want to retain the type information of the keys and values.
reply