I've come to the conclusion that the benefit dynamic typing brings to the table is to allow more technical debt. Now of course technical debt should be repaid at an appropriate moment but that appropriate moment isn't always "as soon as possible". Let me illustrate, say you're adding a new feature and create lots of bugs in the process. Static typing will force you to fix some of these bugs before you can test out the feature. Then while testing out the feature you decide that it was a bad idea after all or that the feature should be implemented completely differently. So you scrap the implementation. In this case fixing those bugs was a waste of time. Dynamic typing allows you to postpone fixing those bugs after you're more certain that the feature and its implementation will stay.
It isn’t even a benefit, really. Statically typed languages don’t force you to model things with types; you’re completely free to use strings and doubles everywhere and take on all the technical debt you want.
People who are only used to dynamic languages may feel like a static language’s type checker is an overbearing teacher (from the 1950s) standing over your shoulder, just waiting to jump on you for every silly mistake! While that feeling is common and valid as you’re learning the language, when you become fluent you see the compiler as more of an ally. You can call on it with a keystroke and it will find many problems that would otherwise only happen at runtime, possibly months or even years in the future!
Moreover, in more advanced languages (such as dependent typed languages), the compiler can actually infer the body of your functions which leads to a totally different style of programming that feels like a snippet plugin on steroids.
It’s true that with static typing, you are usually forced to propagate your changes to “your whole codebase” to get it compiling before you can run any of it. That stinks. However, it turns out you can lift this restriction in static languages, and this is what Unison does:
I haven't given Unison a try so I won't dismiss it outright but to me having to explicitly propagate your change to your entire codebase before you can run anything doesn't suck at all, it's actually the killer feature of statically typed languages.
I've written big applications in python, every time I made a significant architectural change (which might not even be huge code-wise, just far-reaching) I feel super uncomfortable. I know that I have to follow up with an intense testing session to make sure that I go through all code paths and everything still works correctly. Even then sometimes the testing is not thorough enough and you end up with a regression in production.
As a result I tend to avoid these types of changes as much as possible, even I believe that the application would benefit from them.
Meanwhile in Rust it's a breeze. Yesterday I decided to make a small but far-reaching change in an emulator I'm writing: instead of signaling interrupts by returning booleans, I'd return an "IrqState" enum with a Triggered and an Idle variants. It's more explicit that way, and I can mark the enum "must_use" so that the compiler generates a warning if some code forgets to check for an interrupt.
It's like 4 lines of code but it means making small changes all over the codebase since interrupts can be triggered in many situations. In Python that'd be a pain, in Rust I can just make the change in one place and then follow the breadcrumbs of the compiler's error messages. Once the code builds without warnings I'm fairly certain that my code is sound and I can move on after only very superficial testing.
I’d read through the link and see what you think, but TL;DR is that you can still propagate a change everywhere in Unison (and Unison tracks this for you), but even if you’ve partially propagated, you can run all the code that you’ve upgraded so far. So if a change has 500 transitive dependents and you upgrade 7 of them, you can run those 7 (and this might help you figure out that you want to do the change a bit differently) Whereas you’d typically need to upgrade “the whole codebase” before being able to run anything, including code unrelated to the change being made.
Surely if the code was unrelated then you wouldn’t have to change it? It might be tangentially related to the change at hand but the fact it breaks means it must be given consideration and not just as a “geez I have to update all these cases” but also in terms of how the change impacts them.
I’m sympathetic to the idea that you want to solve the immediate problem first rather than have to move the entire codebase through several iterations. I’d be interested how well Unison handles that on a complex codebase in reality though.
The idea is that you wouldn't need large refactorings in more dynamic languages in the first place since you are operating on a different abstraction level.
In the JS based project i recently joined (previously i was writing C# ), what i notice is that in a team of 6 people 4 are doing refactorings every sprint for the past 8 months.
This is talking about the case where you're just testing something out – prototyping it for a few branches before implementing it for the whole thing. In this case, _you_ are your own user, so it doesn't matter if you see the type errors.
If I’m making a potentially wide-reaching change, I’d want to test it against everything else so I know for sure that it’s actually viable in the context of every instance of its use.
Also, some statically-typed languages (like Haskell) allow you to defer type errors to runtime if you really want.
Thanks for pointing me to Unison, I have yet to dig into the details, but its core idea is something I've been thinking about lately and it's fantastic to see that it exists!
To be fair most of the time that type change propagation is just manual work guided by the compiler. Some smart IDE would even be able to make the changes for you.
Yep. Being able to work fast and dirty is a huge boon when testing ideas and figuring out the initial design, and it is deeply annoying that so many typed languages refuse to permit this. The faster you can write code, the faster you can throw it away again; and arriving at good code almost invariably requires† writing lots of bad code first. If writing bad code is made expensive (another example of Premature Optimization), authors may be reluctant to try different ideas or throw anything away.
Ideally, you want a language that starts out completely untyped, efficient for that initial “sketching in code” phase. Then, as your program takes shape, it can start running type inference with warnings only, to flag obvious bug/coverage issues without being overly nitpicky. Then finally, as you refine your implementation and type-annotate your public APIs (which does double-duty in documenting them too), it can be switched to reporting type issues as errors.
Unfortunately, a lot of typed languages behave like martinets, and absolutely refuse to permit any such flexibility, dragging the entire process of development down their level of pedantry and generally making early development an almighty ballache. Conversely, untyped languages provide sod all assistance when tightening up implementation correctness and sharpening performance during late-stage development; it’s pure pants-down programming right to the bitter end.
Mind you, I think this says a lot more about programmers (particularly ones who make programming languages) than it does about languages themselves.
This is why TypeScript is so popular. JavaScript is very loose, but you can progressively enhance it using TypeScript with stricter and stricter types, many of which can be inferred, and since 3.7 can be derived by runtime assertions, and the strictness of which can be controlled with compiler flags.
Additionally, it is fairly typical in js to use functional programming styles, making type inference a breeze / provable / not introducing much boilerplate by adding types.
If you think it helps to quickly write some code without types (I don't really agree with that in general, maybe in some specific cases), but you want to, when you're happy with the design, just add the types, there are many languages that support that!
On the top of my head:
* Dart (just don't give a type and the variable is dynamic)
* Groovy (add @CompileStatic or @TypeChecked when you're ready)
Yeah, it’s not a new idea; but I’d say “some” languages rather than “many”. Dylan’s another case; great pity it didn’t fare better. (I’d much rather be using it than Swift.)
Also, none of them are in the mainstream, which creates general challenges to adoption. Fortune favors the entrenched.
...
Another thing worth considering IMO is structural rather than nominal typing, which fits more with the “if a value looks right, it is” philosophy that untyped language aficionados enjoy without going for full-on pants-down duck-typing. ML favors this, IIRC (I really must learn it sometime).
I find nominal typing can get a bit excessively OCD, demanding so much additional annotation as to obscure the code as much as clarify (“Hi, Swift stdlib!”).
I also wouldn’t mind being able to define sets of permitted type mappings in advance, and letting the compiler figure out where in the code to insert (implicit) casts. For instance, if I’m passing a variable typed as integer where a float/string is expected, it really wouldn’t kill it to promote it automatically; most are perfectly happy to promote integer literals where a float is required after all. (And if I do want to know about it, which I usually don’t, I can always turn warnings/errors on.)
let mut s = Vec::new();
s.push(Pair::new("a", 1));
s.push(Pair::new("b", 2));
s.push(Pair::new("c", 3));
Both are statically typed, but one uses inference. It practically feels like Python everywhere except where type declarations matter most to me -- function signatures:
That IS better than Rust since it migrates the type from n container members to just the 1 container. It doesn't seem possible to replicate that in Rust[0].
My Rust evangelism IS hurt by this. But its HP bar is big[1].
TBH, I think that’s as much accidental as by design. ObjC didn’t do generics until just recently, so the standard collection classes (NSArray, NSDictionary, etc) couldn’t specify element types so had no choice but to leave those APIs typed as `id` (effectively `void`).
Also, being a C, you still have to declare* all the types, even when only `id`, which is tedious AF. Also-also being a C (which doesn’t so much have a type system as a loose set of compiler directives for allocating memory on the stack), it’s not as if declaring exact types does much to improve program correctness. You can still stick any old object into an `NSString*` at runtime; it just dumps stack when you try to send it an NSString-specific message is all.
Oh, and one more: the ObjC compiler can have trouble recognizing selectors (method calls) when the receiver’s type is `id`. Being a C, obviously you have to import all the headers just to use them at all, but if in searching those headers it finds two method definitions with the same name but different parameters then its superficially Smalltalk facade quickly breaks down. (I’ve run into this, it’s very annoying.)
One point based on my observation. Programmers coming from languages with good type system seems to have low probability of being a glueman. While good type system doesn't need to have static typing, in general they tend to have a way to explicitly declare things when you need to.
The current dynamic language ground is riddled with linters and additional tooling that static languages have solved already. This costs developer time (god, the amount of time people spend on writing an eslint config and selling it.)
It's not so much allowing debt, it's that the barrier to entry is extremely low. You can hack around and get a feature "working" without having a great understanding of what you're actually doing.
Statically typed languages will confront you with a lot of obscure errors and just generally get in the way.
Eventually the good developers will eventually learn the language and get burnt by float32 / sql injection / xss / dates / not validating input and they'll learn enough to become extremely productive.
Static languages have evolved a lot; now the standard error messages are far better and the tooling is amazing. At the same time a lot of dynamic features - usually reflection based - have come along and provided their own breed of arcane and nutty rules and errors which seem designed to catch out only the strongest developers (the ones which break SOLID are the best!).
The trade-off is that the bugs flagged by static type checking are usually quick to fix (eg: function expected an int but was passed a float). With dynamic typing, the subtle bug that arises from the function unexpectedly truncating your float could take an hour to track down and outweigh any time saved from dynamic typing. This applies even when trying out new experimental features.
Depends on the language. I do agree that lossy coercions are a Bad Idea; a [mis]feature added with the best of intentions and inadequate forethought (e.g. AppleScript, JavaScript, PHP).
OTOH, a good [weak] untyped language should accept a float where an int is expected as long as it has no fractional part (otherwise it should throw a runtime type error, not implicitly round). Depending on how weak the language is, it could also accept a string value, as long as it can be reliably parsed into an int (this assumes a canonical representation, e.g. "1.0"; obviously localized representations demand explicit parsing, e.g. "12,000,000.00").
A good rule of thumb, IMO, is: “If a value looks right, it generally† is.” This is particularly true in end-user languages where conceptual overheads need kept to a bare minimum. (Non-programmers have enough trouble with the “value” vs “variable” distinction without piling it with type concepts too.)
--
† Ideally this qualifier wouldn’t be needed, but there may be awkward corner cases where a simpler data type can be promoted to a more complex one without affecting its literal representation. That raises the problem of how to treat the latter when passed to an API that expects the former: throw a type error or break the “no lossy conversion” rule? There’s no easy answer to this. (Personally, I treat it as a type error, taking care in the error message to state the value’s expected vs actual type in addition to displaying the value itself.)
You're supposed to have written a test that catches that bug. Which of course also takes more time and effort than simply using a type checker in the compiler of a typed language..
Static types enforced by a compiler catch more bugs in logic that can be encoded in the type system at build time. How costly are these bugs? It probably depends on context. For a business app/SaaS, is the compiler going to prevent your broken business rules from allowing -100 items to be added to a basket, crediting the "buyer"? I would say a developer who knows how to interpret requirements is more important here. On the other hand, a compiler is probably an amazing place for static types, but I don't write compilers and I'd wager most jobbing devs don't either.
Predicate assertions instrumented to run during development catch an equivalent amount and more, since they can evaluate data at run-time.
Dynamic types combined with argument destructuring allows for very loosely coupled modules. I can see it being similar to row polymorphism, but then you have to ask whether it's worth the extra time? In many business apps a significant portion of LoC is mapping JSON/XML to DTOs to Entities to SQL and back. If everything starts and ends with JSON coming from an unverified source, forcing it into a "safe space" statically typed business program is almost ignoring the forest for the trees, possibly even giving a false sense of security. It's (over) optimising one segment of the system; it's not necessarily a waste but it's probably time which can be better spent elsewhere.
To reinforce your point: static typing doesn't just force you to fix some bugs earlier, but it also forces you to spend more time doing design upfront, often when you still don't have clear specs. That can be a big drag when you need to do a lot of experimentation/iteration.
But you don't need to have a final design straight from the beginning. You can start with only what you need right now and evolve the types/schema over time.
In fact, proponents of static typing would argue that the types make your code easier to refactor later, because you will be aware of all the usages, and able to move things around with confidence.
The drawback is that you need to make the entire codebase correct (or commented out) every time, not just some isolated piece you're experimenting on.
Interesting, I would have said static typing allows more technical debt. Illustrating example: Lets say you pass a double variable from some part of your code through 13 layers of APIs until it is actually looked at and acted upon. Now you realise that you not only need a double, but also a boolean. In dynamic typing, you can make a tuple containing both and only modifying the beginning and end points. In static typing you have to alter/refactor the type everywhere.
It’s often considered bad practice to pass around raw values for that very reason. Introduce a (minimal) abstraction, that is, give the thing you’re passing through those layers a name. Then you can change the endpoints at will and still get static checking (as a plus, you can‘t accidentally pass the wrong bool, or mix up the tuple order).
I agree with the GP‘s point that static typing forces you to do that kind of design work earlier.
(Edit: You raise a good point, though. I think a lot of people run into this kind of problem with static typing.)
You'd love Perl. Just pass @_ through your callstack. Want a new value available 20 functions deep that is available at the top? Just toss it in @_ at the top and you are done.
The problem? Every one of your 20 levels of functions/subroutines has an unnamed grab bag of variables. You get to keep that in your head. If you want to know if you have a value in a given branch of code, your best bet, aside from reading the code of the entire callstack, is to dump @_ in a print statement and run the entire program and get it to call the function you are in. Oh, and if one of those values contains a few screens worth of data, you will need to filter that out manually. Even "documentation" in the form of comments or tests will be unreliable due to comment-rot or mocked test assumptions.
Even in Python, I'll often have to go up the callstack to know what a named parameter actually is. And if similar shenanigans are going on, I again have to pull out a debugger or print statements to know what I can do with an argument.
With a static type, I see plain as the text on my screen what type I have as a parameter and I immediately know what I can do with it. As weak as Go's type system is, it is worlds better than Perl and Python for maintaining and creating large, non-trivial codebases. The price is passing it around at the time of writing.
And on layer 10 you missed that you were using and treating the argument as if it were the double. Now you have a bug that the type system would have solved. Or you can alias that input early if you know with a good certainty it has possiblity to change, and now you just update the alias and everything gets checked all the way down without a refractor.
I think at that point you probably need a major refactor, regardless of the language being used. While I don't want to be in that situation, I'll typically use a helper class if I'm forced to pass something through 13 layers (a class containing all the arguments).
However, that's pretty much the textbook example for designing with dependency injection in mind. Static typing won't fix a terrible architecture.
I haven't read that textbook. Do you have a good reference. I may have been intentionally been using an anti pattern. I often have a POD class that gets passed down a processing chain.
YMMV. There's probably at least one good use case for virtually every design. What you're doing could be totally reasonable.
The original description sounded like 13 functions being manually chained together, each inside the last. Where to go from there is very situational.
Using a POC is pretty much a given.
For transformation in the simplest form, I'd typically have a series of extension methods (or some equivalent, like a transformation class which only has methods and holds a reference to the object) which can be called to transform and return the object or return a modified copy of the object. That's easier to test and debug as each method doesn't need to know about the one above/below it (they get called in parallel). You can also easily modify the order of the transformation, or cut out parts of it. When testing/debugging, you just initialize data to some state, then test the broken piece of the pipeline.
If the 13 vertical calls are for initialization, DI allows you to turn that into 0 calls. You probably need reflection in your language to make DI work though. I've never liked DI frameworks built by others, and have generally made my own.
All that said, I'd stand by my point that if 1 item is passed vertically through 13 functions before it gets used, then there's probably some way to either make things a bit more horizontal, or to automate the passing.
Another strong belief: If DI or some other "good" design tool results in more lines of code or significantly more confusing code, then either it's being done wrong, or it isn't applicable to the situation. There are generally more wrong places to use a tool or pattern than there are right places to use them. A power drill is a terrible hammer.
I'm keeping that line. More wrong places... That is koan worthy. What is DI dynamic initialization? I can't imagine passing a const bit of POD through 13 layers without it bein used, but not every layer uses all of it probably.
It's basically a fancy way of saying 'Use reflection to read all of the dependencies that exist in my code, and then automatically initialize those dependencies and pass them to the classes which need them.' This (for me) is a recursive process which starts at root "singleton" objects (single per DI session), then moves down through the tree that is my code and its dependencies.
Typically, dependencies are passed via constructor then stored in a field. I personally think that's a dumb way to do it, as I'm using DI to eliminate boilerplate code, and you don't often get code more boilerplate and tedious than copying a variable into a field. I instead mark fields with obvious attributes that show that DI is used for initialization, then have my DI framework automatically fill them in.
People often overuse interfaces with DI, which leads to very difficult to trace code. I consider that an anti-pattern unless writing a library for others. Unfortunately, I think the people doing this have made DI seem like a trade off: you remove boilerplate code, but you lose clarity. You'll see this in virtually every DI tutorial, which is unfortunate. DI can be done just as well with concrete objects.
If you're using a language with reflection, I'd highly recommend learning a DI framework. Once you've figured out what you don't like you can find a framework that works the way you prefer, or in the worst case write your own. You definitely have to structure your code for DI, and it's difficult to add after the fact. That said, I was able to chop 5 to 10k lines off a 100k project I converted last year, and more importantly adding new functionality became much faster and easier. Again YMMV.
> Lets say you pass a double variable from some part of your code through 13 layers of APIs until it is actually looked at and acted upon. Now you realise that you not only need a double, but also a boolean. In dynamic typing, you can make a tuple containing both and only modifying the beginning and end points. In static typing you have to alter/refactor the type everywhere.
Adding a double and boolean field to a Context object, I don't have to touch anything at the intermediary API layers. Just as with your tuple/dict.
According to this, languages statically typed but offering a dynamic type (any in typescript) allow best of both world.
And using the right types is a matter of refactoring.
The hard part is not offering a "dynamic" type (which is generally just a variant type with a bunch of RuntimeType cases) but making the "static" and "dynamic" portions of the language truly, tightly interoperable. Generally, this implies that the compiler and runtime system should be able to correctly assign "blame" to some dynamic part of the program for a runtime type error that impacts "static" code, and this can be quite non-trivial in some cases.
I agree, and I think that's one of the reasons TypeScript works so well, and why it's a shame that some people dismiss it beforehand because they generalise from other statically typed languages to TypeScript. Not only are you able to consciously take up some technical debt somewhere, but it's also clearly marked by the type system.
Which is entirely plausible. If all the decisions about what data should be flowing where in a program have been made there is no particular reason not to have static typing. It won't make things worse, will enforce discipline and will probably catch bugs.
As far as static types are feasible they are great to have. Clojure's spec is the gold standard I work to; if anyone has a better system it needs a lot more publicity.
>If all the decisions about what data should be flowing where in a program have been made there is no particular reason not to have static typing.
But this is exactly the GP's argument, no? According to the agile philosophy (and my personal experience), we don't have enough information at the start of the project to make right decisions about all parts of the implementation - so we try to decide about just the most crucial architectural aspects and make the rest as easy to change at possible, learning as we go.
From this perspective, the argument is that dynamic languages make it easier to postpone some decisions and allow us to quickly experiment with the things we've left flexible. And then, over time, once we're more confident in our decisions, we can put in the effort to repay this technical debt and lock things down to some desired level.
> - so we try to decide about just the most crucial architectural aspects and make the rest as easy to change at possible,
that is exactly why I like static typing - I can just change stuff and ask the compiler to kindly list all the places that need to be updated to accomodate for the change.