> prefer simple built-in data structures (primarily hashes) over custom objects
I've found this is actually one of my biggest problems with functional code as currently written: people seem afraid to just declare a struct/record, in lots of cases where it's obviously the right thing.
If everything is a hash, you've just made all arguments optional and now you've invented a bad type system inside your good type system.
If everything is a tuple (more common in my experience, from reading Haskell), now you know (int, int) is actually a pair of ints, but you've thrown away variable names and nominal typing: is it a 2D vector, a pair of indices into an array, or something else entirely?
Defining custom types is the elegant solution to this: you have `struct range { start: int, end: int }` and now your functions can take a range and everything is great.
It's not about being afraid to define a struct/record (rigid type). It's about being afraid of being stuck with something that doesn't meet your needs later or elsewhere, if only by a little bit.
That Range type is great until you have cases where you want variations on a Range, such as a MeasuredRange (same start and end, but with a new field called step_cost). Original functions which take a Range can't cope with this new completely different struct called MeasuredRange. So now we have to change them all to accept Range or MeasuredRange, or we need some kind of type heirarchy to relate them in appropriate ways.
The alternative is to accept the runtime risk of receiving a thing which doesn't have the start and end that you needed. Or of course if it were a very important piece of logic where failure was not an option, you just make your do_rangy_thing() require explicit start and end parameters. Then it's up to the caller to call do_rangy_thing(my_range[:start], my_range[:end]). Ultimately that just moves your failure risk up one level, though.
Likewise, you can use hash destructuring in Clojure like (do_rangy_thing [{:keys [start end]}] ... or pattern matching in Elixir like def do_rangy_thing(%{start: s, end: e}) ..., both of which will blow up at runtime if the hash passed in doesn't have the required keys.
Many people accept the runtime risk because it greatly simplifies code at the cost of runtime safety. The worst thing, in my view, is when languages try to bolt on some kind of type strictness later and end up solving the Range vs MeasuredRange problem by listing all possible accepted types or just throwing up their hands and saying Any.
I don't know Haskell, but from what I've heard about it I find it surprising that (int, int) is as common as you say. I thought they were very much about detailed specific types, with the theory "if it compiles, it works". My guess is that generic tuples are just an indication that some of the participants didn't want to deal with the complex type system and have a thousand different narrow case types defined.
re Haskell: I just looked at a few arbitrary files in arbitrary Haskell projects on Github, and I retract that part. I was either thinking of another language, or had seen a lot of bad Haskell code before that I cannot find now.
----
Isn't this just the nullability thing again that most people have decided was a bad idea, but with a different name? Instead of two ints, now your function takes two maybe-ints and crashes at runtime if they're not there.
I do think the thing you're asking for (interfaces but for fields instead of functions) is a missing feature in most languages. I understand why it's there, but it's still annoying.
The most well-known language I can think of that explicitly supports it in an otherwise static type system is TypeScript, where a type can be "object containing non-nullable ints called start and end, but with no constraints on other fields that may be there"[0]. It's technically a hash map at runtime, but it's a compile-time type-checked hash map.
In other languages, you could bodge it with a bunch of Java-esque getX/setX functions. I don't blame people for not doing so.
> Instead of two ints, now your function takes two maybe-ints and crashes at runtime if they're not there.
The idea in Haskell (and many more, I'm sure), is that if you take a maybe-int, you can't get the int without unpacking it (or using a function that only operates on the int if its there), so then when you pass the unpacked int around, you are 100% sure it's there at all times. No worrying about a JS-style undefined, or some other function altering the value to become null again. It's a guarantee, which is a huge part of why all this FP stuff is so great.
I've found this is actually one of my biggest problems with functional code as currently written: people seem afraid to just declare a struct/record, in lots of cases where it's obviously the right thing.
If everything is a hash, you've just made all arguments optional and now you've invented a bad type system inside your good type system.
If everything is a tuple (more common in my experience, from reading Haskell), now you know (int, int) is actually a pair of ints, but you've thrown away variable names and nominal typing: is it a 2D vector, a pair of indices into an array, or something else entirely?
Defining custom types is the elegant solution to this: you have `struct range { start: int, end: int }` and now your functions can take a range and everything is great.