Ask HN: What is it like working on a large dynamically-typed codebase?

21 points by crummy ↗ HN
I have worked on small-to-medium projects but only in strongly typed languages such as Java or TypeScript. I rely heavily on my IDE to tell me where a field is used, or to allow me to rename a method and still be confident that the callers have been updated, or add a parameter to a method and know my code won't compile until I've fixed the callers.

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?

56 comments

[ 2.6 ms ] story [ 114 ms ] thread
Writing tests help with refactoring.
It doesn't work well, not for everyone on the team at least. Static typing helps alleviate a lot of the bugs you'd normally get with a dynamic language. As of my opinion now after working on a medium/large size dynamic js project. It bit us more than I can count and simply adding in ts helped find a few long outstanding bugs that kind of went away as the develops found those issues during the migration.

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's pretty terrible IMO.

* 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

And please note: whilst Python tooling is brilliant, absolutely brilliant, it cannot match what you have in eclipse for java tooling or msvc++ for C++ or C#.

And pytypes doesn't really change that.

While this is all true, some things are much easier in Python because it is a fairly high level language. We had an in-house language for web pages (history) whose runtime we had upgraded to Java (originally, C++). Trying to write tools that analyzed and helped debug those pages was painful, because... everything had to be reified into types, and what Graham says about sculpting is true. Java is an awful language for exploratory programming.

Horses for courses.

You still need types for objects in Python - the only difference is they're in your head (or annotated)
Yes, but their implementation can be sculpted without as much protocol (interfaces et cetera).
And in most places exploratory code becomes production code because that’s life.
Sometimes. And that's fine, for values of "production" that are "occasionally we need to run this to understand/debug this other code".
And if you think Eclipse tooling is great, wait till you try IntelliJ!
Pain. You write endless tests to ensure that you cover for all types coming into critical code paths, but you can't cover for all of them. At the same time, you have no guarantee of the type of something like a function argument, so a huge amount of time is wasted if you accidentally misread or assume the wrong type.

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.

I was just saying in another comment how I wish the ML community would replace python with a statically typed language with truly concurrent multi threading. FastAI tried to pivot to Swift but unfortunately failed.
You can write `assert`'s in your code at the top to limit the scope of types coming in or handle them if the values are bad.
A nightmare, and tests don't do as much as old boomer TDD people say they do. IDEs can still do things with dynamically typed languages, but if you have an object that calls some very generic method name like `get()`, there are probably many classes with that method. The IDE can try to guess the one but it's a lot of visual noise and less precise than just being able to infer it accurately.

Languages that support type annotations (Python 3) help and AFAIK most good Python teams working on relatively recent code bases use them.

Also, even if your IDE can pick up on function usages elsewhere, if they are invoked or referenced in a non-standard way, they will probably be missed (such as Function.apply, as arguments, etc.)
I worked with large-ish Python codebases focused on “full-stack ML“ (data pipelines ending with trained models and APIs for using these models). Some of my colleagues relied on PyCharm for refactoring. I preferred to use cli tools (vim + tmux, ripgrep) and ran a linter from time to time. Ideally, we should’ve integrated the linter with CI, but it was in a deep backlog.

Pycharm seems to be great with all kinds of refactoring.

> Ideally, we should’ve integrated the linter with CI, but it was in a deep backlog.

This is a P0 for me in a Python project, crazy to backlog something so useful. You can even turn it on for only errors so it's a little quieter, then gradually address < Errors over time.

If you have a disciplined, experienced team; good documentation; good coverage with automated tests; good linting; good code organization; the team avoids clever coding (e.g. lots of meta-programming), and you're comfortable leaning on UNIX as a larger part of your IDE, then it's not too bad. Of course, everything is a tradeoff. You may find that some things with dynamic typing become easier, or possible that weren't before.
This question is bait for HN. Whether it's a huge pain or not (I happen to think it is) the "dont use a dynamic language for large projects" crowd here is very vocal and they will certainly tell you it's an awful experience you should avoid.
Yea, but you don’t always get to choose. Or rather, maybe it’s not that bad all things considered. And either way, what are the takeaways?
Code organization can go a long way but even then you will want to be very familiar with source. Talking about a large code base, you have to be even more intimately familiar since things begin to leak even with decently-strong organization. Linters go a good way in enforcing consistent style but that's a relatively small contribution to the issue.
> How did you make small refactors for example?

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.

(comment deleted)
To address the meta question of whether you "should" do this or not I think it's worth considering the world when these choices were made. The choice used to be (essentially) ruby/python vs. c++/java. C++ and Java absolutely suck (imo) to write all day. Whereas python and ruby are more of a joy. So you get to hate your environment and language all day for the eventual upsides of having a large codebase, or you get to enjoy your day to day with a big problem waiting for you if you hit the success metric of having a large unwieldy codebase. Most startups it's so obvious that you'd choose the first one. Then you can hire the masses to fix it later!

Maybe with languages like Go we can have both now?

I haven't used Pycharm but Rubymine for Ruby is pretty amazing with refactoring help in that regard.
I don't miss it all that much, though in general I am a fan of types. Granted, I have never worked on a "large" statically typed project. I think if the language has pattern matching that helps quite a bit.

(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.

I’ve worked on a 700k+ LOC Perl codebase. Testing and linting made it possible. I’m pretty sure that a similar amount of tech debt would have been there in another form if it has been a typed language.
I find the only way it’s workable is you write a ton of unit tests that essentially mimic a compiler.
In my experience, it's pretty difficult, particularly in middleware code that passes values received from one part of the code to another. There was a lot of code tracing, both up the stack - to figure out where the values are actually coming from, or down the stack - to figure out what the code that ultimately uses them is expecting. I ended up leaning on interactive debugger sessions pretty heavily, since you can peek at what kinds of value are actually going through the system, but that tends to emphasis the "happy path cases" and it can be harder to track down the edge cases.
I worked in a large python codebase with no types and no tests.

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.

>if they were too arcane (like Union[Dict[Any,Any],str,IO,Tuple[str, IO]])

How about

    myweirdtype =Union[Dict[Any,Any],str,IO,Tuple[str, IO]]
Than just reference it by name.
Personally found that this really, really sucks and I’d prefer to never do it again.
I've worked in multiple large dynamic and static codebases. Everything is a major PITA when you have a large codebase. At least that's my experience thus far.
I used to work in very large Ruby codebases, and have also worked in some midsized Python ones. If you rely heavily on your IDE, these languages will be painful. Rubymine is surprisingly good for what it is, but it's solving an intractable problem.

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.

> If you've worked on a large Python or JS codebase, what was it like to work in?

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.

Refactors in a large dynamic code base are only small if the API you're refactoring has a small footprint in usage. Others have already given reasons why: tests will break, runtime issues will crop up, etc. The lack of static typing can really hinder refactoring of any size--even of "internal only" code if metaprogramming has been employed.
I don't think the codebases I have worked on can be considered large as such. However, to me, they were actually easier to change/refactor. There was less boilerplate, and more concrete implementation than what I see in static language projects. I usually have a harder time working with static languages because of so much boilerplate they usually have. I seem to be in a minority though.
I've worked on several large ruby codebases. It's fine. Unit test coverage has always been pretty good.

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.

If you really want to just give it a go and see, pick any part of WordPress and try to make a contribution (or simply change something locally). The editor is React/JS and the backend is legacy-ish PHP.