I can't see how this can work at scale in a large project without compile-time typesafety. I suppose 100% test coverage could provide some of the benefits, but not all.
If you've worked on a large Python or JS codebase, what was it like to work in? How did you make small refactors for example?
The mindset is a little different in these languages and codebases IMHO - rather than a "very scientific" approach to refactoring and programming with a heavy reliance on the IDE, compiler, and analysis tools to get things just "right" on the first try, there is a more "system in the loop" approach to refactoring. Code is refactored, tests are run, failures are addressed using error output, and then full-stack or end-to-end tests are performed to validate a fix.
You're still doing most of the same thing, just at a different point in the development cycle. I think it's hard to argue that refactoring isn't "more expensive" this way. On the flip side, there's no "it compiled so I ship it" going on - an end to end understanding of the refactor and what it touches is essential.
Also, oftentimes your codebase ends up brittle enough that you need to start introducing speculative runtime / "testing in production" features - feature flags, phased rollout, in-depth error telemetry, etc. This is again expensive but at the end of the day I suppose a brittle core can lead to a more resilient system overall.
Refactoring in Python wasn't automatic, but it wasn't hard either. Most folks weren't clever in their work, which is to say there wasn't a lot of non-obvious metaprogramming going on. It meant refactoring wasn't a hard task most of the time.
There's a lot of things I miss about Python. The lack of insistence that everything is a class, the lack of DI bullshit, a lot more functional programming tools... etc. And yes, I'd rather monkey patch than deal with spring's DI fuckery. Every. Day. Of. The. Week.
In Ruby we generally rename a method but alias the old method name to the new one. Flat out removing a method (whether by renaming or deleting) is nearly unheard of, especially in a legacy codebase.
I don't rely on my IDE much at all. I use runtime introspection to discover the objects at play and what they're capable of. If I'm trying to figure out where a method comes from and it's really unclear, I can access the class, call the `method` method with that method name, and get the source location of that method's code.
There are lots of other techniques and approaches to working with dynamically-typed languages. I'm mainly familiar with Ruby so that's why I'm talking about it.
The key takeaway IMHO isn't that dynamically-typed languages are "missing" features from statically-typed languages, therefore they're somehow "worse". It's that many of the techniques concerning programming in general are simply different. Whether or not that's better or worse in the long run is really subjective by and large. (Personally, I don't like statically-typed languages. I really don't.)
Case in point: I wrote and maintain a mid-sized critical service at work, say 50k LOC or so. Yes, testing is an issue, you do find it's hard to test all branches etc, something statically typed would be a bit easier. But it's fine actually, well tested, well thought through, and refractors are fine.
Meanwhile, my python service connects to a C++ service written by a contractor, which is absolutely awful and crashed all the time, or worse, produces totally incorrect results. No doubt it compiles though.
One thing which is real is that barrier to entry to dynamically-typed languages is much lower and there is simply more crappy code around. Also these tend to be used by people who are programmers but not software engineers. Data scientist don't write prototypes in C++.
There will always be opinions on benefits of vanilla js, but anything that needs to scale (team wise and communication tax) is simply easier with a static language.
It was.... barely ok. Honestly I found that the biggest problem with no types was less specifically about "oops you passed a dict in but it was expecting a string haha runtime error" and more about:
[line 2359 of somefile.py, 20 calls deep]: def snafucate(id, f, connection):
[me]: ok connection is the same db connection we've been using. id is the UUID. What is f though? (clicks find-usages and picks the probably correct call site)
[that call site]: def snafucate_caller(f, connection):
[me]: [repeats this process until I deduce that f is at various points in the codebase at various call sites and sibling methods: a file IO object, a filename str, a custom type that represents a file path, a 2-tuple of (filename str, file IO object), and in one code path that I think it's actually unused, a dict that's never populated with anything.
And just lots of crap like this. It's some awful combination of poor rigor about naming, not consistently using the same schemes for args (do you usually take in IO objects, strings representing paths, or t.Union[IO,str]?), no testing, poor documentation, no type hinting, and of course the good ole' "this code has had 12 owners across 20 years, ranging from well-meaning new grads, to "experienced" people who are bad at coding, to ninjas who are too terribly clever for the rest of us to hope to keep up".
And yet somehow we still got stuff done in it. And one of the things I tried to do when working in it was either adding type hints once I figured them out, or if they were too arcane (like Union[Dict[Any,Any],str,IO,Tuple[str, IO]]) I would try to squash them down enough to make the type sane. As I was slowly dragged through the codebase, the types slowly started peppering in, and it got easier and easier, especially as I got promoted and had some juniors under my wing who I could ask to help do the same gradual-typing-as-you-spelunk process.
I kind of miss it tbh, one of the things I was doing right before I left was toying with proposing an official set of domain-specific type hints and type aliases that would've made the whole codebase a lot easier to follow if people were willing to join me in my quest.
* IDE refactoring is impossible because it can't infer the types, so if you do want to make large refactors it's a huge time sink
* It takes 3x longer to read and understand what the code is doing
* Requires almost twice as many unit tests for things that would normally be resolved by the compiler
* ci/cd can actually be slower since there's no incremental compilation
Static typing often means people skip tests and that can lead to an untested, incomprehensible system that correctly passes types.
Also, strong typing and dynamic typing are not opposites.
https://wiki.python.org/moin/Why%20is%20Python%20a%20dynamic...
As much as I like typescript I've not really desired types in ruby. For small refactors you tend to be changing just one small part of a class hierarchy or interface and you're not going all over the place updating class signatures. For radical changes you're replacing an old concept with new in every location so you'd be giving it a bit more eyeball.
I'd also point out that people didn't have IDEs like that in the past even with strongly typed languages yet they still managed. I definitely use that tool now, but I see it as only an incremental improvement, you still have to know your codebase and understand the change you're making.
Maybe with languages like Go we can have both now?
Easy, you don't. All code is write only, and if something does need to change, you simply insist on a rewrite and creating an even bigger mess than what existed before.
In Clojure? A breeze. Data is just data, no behaviour. Destructuring shows what a function requires right in the parameter definition. You are already using schemas for validation so you can annotate them in all the functions that receive/return them.
IMO dynamically typed OO languages scale very poorly with codebase size, unlike their statically typed counterparts.
Rife with footguns.
Things broke all the time until we eventually re-added some form of static type checking.
> How did you make small refactors for example?
We'd write tests first to validate that _wrapped_ large chunks of functionality, commit those, then refactor. The key was to write these tests even if the existing code already had "unit tests" because we often found issues this way.
Pycharm seems to be great with all kinds of refactoring.
I'd add, it's more than just the language. I've just started working with Dart, which, as a language, is broadly comparable to TypeScript, but the big difference is how much more pleasureable and productive the Dart ecosystem is, vs anything else I've ever used.
(About 72k LOC, not sure if that's considered "large".)
EDIT: Well, I guess I did work with some Hack code while contracting at FB, so that's definitely a large code base.
One glaring issue I often see overlooked is this: in nearly any project, most complexity and runtime errors tend to come from external data sources: databases, external APIs, user input, etc. The godlike powers of static languages and advanced IDEs don't help you so much there. So in practical reality for most projects this greatly levels the playing field between dynamic and statically languages.
> I can't see how this can work at scale in a large
> project without compile-time typesafety.
Moving from C# to Ruby, I thought compile-time type safety was going to be the biggest issue. It has been extremely close to a non-issue. This is going to sound almost absurdly low-tech but the answer for me is literally just "use descriptive identifier names" which is something you should do anyway. Method/function names should strongly imply the return type, ie "get_user_ids()" and not "get_stuff()". This makes most type snafus glaringly obvious; "current_temperature_centigrade = get_user_uids()" is glaringly wrong. > I suppose 100% test coverage could provide some of the benefits
Yeah, that's part of the idea. The idea is that you should have close to 100% test coverage anyway and your tests are going to blow up if you screw up your types. > I rely heavily on my IDE to tell me where a field is used
If you have nice descriptive identifier names (see above - something you should be doing regardless of dynamic typing) then grep or silver searcher is usually far superior in speed and functionality to any IDE's built in "find in files" IME. And your grep-fu (or ag-fu) is going to be useful in all kinds of situations. When feasible, master tools that will serve you for life instead of getting good at some IDE you'll ditch in a few years. > or to allow me to rename a method and still be confident that
> the callers have been updated
This is definitely something you give up in a dynamic environment, at least the auto rename part. However, the finding part is pretty well handled (see above). > If you've worked on a large Python or JS codebase, what was
> it like to work in?
For Ruby, at least, here are my conclusions.1. You do make some sacrifices as discussed above.
2. In exchange you get a massive gain thanks to being able to work with, prototype, and debug live code in the Ruby console. I have found this to be more than worth the sacrifices. Also in my experience people who don't like Ruby generally are people who have not taken advantage of this and therefore only experience the downsides.
3. Overly large monoliths are hell in any language, dynamic or not. The flaws of a dynamic language are exacerbated in overly large codebases, but I've seen some sheer monolith hell in static languages too.
4. Sticking to a well worn paradigm e.g. MVC alleviates a lot of pain regardless of typed vs. untyped.
Data scientists giving me code and not telling me what Python libraries they are using or which dataframe to use has caused a week of headache, personally.
Languages that support type annotations (Python 3) help and AFAIK most good Python teams working on relatively recent code bases use them.