Many moons ago, the majority of software development was done using statically typed and compiled languages, the most popular ones being C, C++, and then Java. When dynamic languages began to take off, at first they were derided as "toy" languages, an epithet most commonly associated with JavaScript. Over time, the advantages of interpreted, dynamic programming began to win the hearts of the community.
Today, especially in the web app world, you're as likely to work with a dynamic language as with a static one. JavaScript and Python are consistently within the top five popular languages today, with others such as PHP and Ruby always in the mix.
Dynamic programming languages come with a host of benefits, including:
- They are interpreted rather than compiled, which means there is no additional process to run or time to wait when executing the code.
- The code itself is typically much more concise, since you don't spend as much time defining and casting types.
- You can create domain specific languages (DSLs), where you can define application routes or configuration in a much more natural-looking way.
- You can typically "open up" or "monkey patch" types at runtime and make changes to them, allowing you to easily add functionality even to objects owned by third-party libraries.
- You can have mixed-type collections such as arrays and dictionaries, something which is hard or impossible to do with statically typed languages.
- Type coercion is typically less painful—rather than having to override a function many times with the different permutations of input parameters, you can have one function definition that takes an argument of any type and coerce it to whichever one you need.
- Metaprogramming frees you from the stricture of third-party code and allows you to make minor changes to it without having to wait for the fork/pull request/merge flow, which can take months or years depending on the project.
The astute among you will likely have noticed that pretty much every single item on this list can be interpreted as a downside as well as an upside. Taking these one at a time:
- Interpreters won't catch as many problems as compilers.
- Being less precise about types introduces additional errors.
- DSLs can be confusing and add to the mental load you’ll be carrying for the application and language.
- If anyone can open up a type, they can easily misuse what should be internal code.
- Mixed-type collections tend to be used incorrectly and confusingly.
- A single function definition for many types can get messy.
- Monkey patching third-party classes can result in stale code and difficulty upgrading.
As dynamic languages have grown more popular, practitioners have had spirited discussions as to how to solve many of these problems. My own feelings about dynamic languages have slowly tempered my excitement about the freedom they provide with a strong dread of the pitfalls.
My primary work language at the moment is Ruby, which is possibly the "most dynamic" of the popular dynamic languages. However, even in Ruby, I've found a set of practices which I feel can help mitigate some of the downsides of dynamic programming.
Type hints exist. Use them.
Over the past decade, every one of the major dynamic programming languages have introduced some kind of type hint capability. The most famous and widely-used is probably TypeScript, which is essentially a superset of JavaScript that requires a transpiler to turn the code into "regular" JavaScript. Both Python and PHP have introduced type hints as part of their standard library, and even Ruby has experimented with the RBS and Sorbet projects (sadly, there doesn't seem to be a torrent of support for either).
Type hints are the most obvious way to make a dynamic language more static. In effect, you get the best of both worlds: you can write dynamic code but are required to be more careful about what types you expect to get and use at any point.
Good type hint systems are ironically much more complex than static languages' type systems usually are. In Java, you can't declare that an object's property can be an integer or a string—but TypeScript makes this dead easy with union types:
printLabel = (label: string|number) => string {
console.log(`Please fill out ${label}`);
}
This is because type hint systems don't try to turn dynamic languages into static languages; they merely provide a way for you to document your expectations in a way that machines can understand and enforce, either at compile-time or runtime.
Finally, most type hint systems allow gradual typing, where you can turn the hints on for one file at a time, rather than have to convert your entire codebase at once. You can make use of this to slowly move your code over to your type system rather than require a multi-month project to do it all together.
Dictionaries are for unknown data
A common pattern in dynamic languages is to use a dictionary or hash as a way of representing unstructured data. When returning data from a function or passing options to a function, dictionaries often are the method used:
def configure(options={})
self.logger = options[:logger]
self.host_name = options[:host_name]
end
or:
return { count: 5, average: 10}
These dictionaries can provide no type hints because they are meant for arbitrary key-value pairs. Using them to represent known data makes it harder to infer types or to find errors. In the first example, if you accidentally pass :loger instead of :logger, it will simply set your logger to null rather than throw an error (which is what you really want, since it's a mistake in the calling code rather than the data).
For type hint systems that support interfaces or duck typing, such as TypeScript, you can continue to make use of this feature:
interface CountResult {
count: number;
average: number;
}
getCount : CountResult = () => { return { count: 5, average: 10} };
For languages that don't support this, use structs or data classes. This is a simple type, often immutable, that contains a set of fields:
ConfigurationOptions = Struct.new(:logger, :host_name)
def configure(options=ConfigurationOptions.new)
self.logger = options.logger
self.host_name = options.host_name
end
The simple move from index-notation to dot-notation introduces a level of type safety that you can get without any type hint system at all. If the method doesn't exist, an error will be thrown and you'll know it right away.
Even if you're not using a type hint system, you can document your fields. Many IDEs will pick up on these comments and will help you with auto-completion. This might make for a more verbose definition, but it's much easier to reason about:
class ConfigurationOptions
# @return [Logger]
attr_accessor :logger
# @return [String]
attr_accessor :host_name
Limit your frameworks
What's the difference between a framework and a library? There probably isn't an accepted definition, but in my mind, a framework takes over your code. You're no longer writing Ruby, you're writing Ruby on Rails. You're no longer writing Python, you're writing PySpark. The code has its own look and feel that differs from your vanilla language. Libraries, by contrast, are something that you call when you need them and don't look significantly different from your own code.
Frameworks exist in static languages, of course—Java with Spring Boot looks very different from plain Java. In dynamic languages, though, the existence of DSLs means it practically looks like you're writing a completely different language entirely. Here's how you define routes in Rails, for example:
Rails.application.routes.draw do
resources :users, only: [:show] do
post :log_in
end
end
My strong feeling in this area is that you should limit the number of frameworks in your app. Typically you'll use one "big" framework (like Rails or Spark)—once you have that big framework, try not to include other dependencies that will make your code look even more different, especially if they try to be "smart" and do metaprogramming or reflection to act on your application's code.
I've been guilty of this even very recently, by writing a configuration library that defines a DSL rather than a more explicit way of interacting with the configuration options. Since then I've come further down on the side of being explicit. Speaking of which...
Be more explicit than you need to be
Static languages enforce explicitness, often to the point of rigidity. If you want to know what a piece of code is doing, it's easy: you ctrl-click into it and follow the call stack down. There is no "magic" involved.
For dynamic languages (and in this case I'm including languages like C that allow for defining macros), it becomes much harder to do. Dynamic languages often fail the "grep test"—if I see a method or a particular syntax, can I find where it's defined by searching the codebase and/or dependencies? If not, it's doing too much metaprogramming.
The only real things that should ever be doing metaprogramming or reflection are frameworks themselves. You can design your own internal framework—that's totally fine! If it's meant to solve a common problem unique to your team or company, that's the sweet spot for frameworks. But don't assume that people know every warp and weft of the effects of what you're providing them if it's not explicit.
How do you know if you're not being explicit? If you look at a problem and think, "I can solve this really concisely, or I can take the time to lay out all the bits and pieces so they're visible, and I don't want to spend time doing that," nearly always, you should spend time doing that.
Here's another example. In our Rails app, there are a number of job types associated with a flyer. One way to define these associations is by looping over the job types:
JOB_TYPES = [:process_image, :upload_image, :process_tagging]
JOB_TYPES.each do |type|
has_many "#{type}_jobs"
end
This works! But if I see flyer.process_image_jobs somewhere in my codebase, how on earth am I going to know where it comes from?
It's more work—but more informative—to be explicit:
has_many :process_image_jobs
has_many :upload_image_jobs
has_many :process_tagging_jobs
In general, try not to violate the Principle of least astonishment. This means:
- Don't monkey patch or change behavior of objects that aren't yours, especially base types.
- Don't create methods programmatically; make sure you can search your codebase for whatever you write.
- Try not to use implicit state (like the this keyword in JavaScript outside the scope of a class). Be explicit about what object is being worked on.
- Don't wire up code automagically (e.g. by reading a particular file structure—StimulusJS does this and it's one of the things I don't like about an otherwise really nice package).
There is definitely a time and a place for being fancy. Choose your spots wisely!
Don't be tempted
Dynamic languages give you plenty of freedom—enough rope to hang yourself, as they say. Enjoy the freedom, but try to think more statically when you can.