🎭

3.5. Explicit is better than implicit

You've probably heard this expression many times, but in this chapter, I want to reveal its essence through real examples:
Abstraction vs Pattern Matching
The task is this: "we have data parsers for two types of devices, we need to make a parsing function that will use one or the other type of parser."
How such a task is solved in OOP:
// # Create implementations of the first and second parser class FirstTerminalParser { parse = (data: string): void => { // ... } } class SecondTerminalParser { parse = (data: string): void => { // ... } } // # Describe a common interface type Parser = { parse: (data: string) => void } // # Implement our function const someFunction = (data: string, parser: Parser) => { parser.parse(data) }
We used a technique called "Interface polymorphism", that is, since our classes fit the interface, we can use them in this place.
This is one of many solutions available in OOP, but like most of them (especially the most common ones), it uses "abstraction" in the form of an interface.
But here's how the same task is solved in a Functional style:
// # We can also use class notation if we want class FirstTerminalParser { parse = (data: string): void => { // ... } } class SecondTerminalParser { parse = (data: string): void => { // ... } } // # This time our interface doesn't "generalize", but specifically indicates "either/or" through Union Type type Parser = FirstTerminalParser | SecondTerminalParser // # Implement our function const someFunction = (data: string, parser: Parser) => { parser.parse(data) }
As you can see, the difference is 1 line, but in reality the depth in this difference is incredible:
  1. First, now "Parser" is not something abstract, but specifically one of two types (which means all type hints will work even better)
  1. Second, we can now use Pattern Matching
class FirstTerminalParser { name = "FirstTerminalParser" as const; // # Add a field by which we will do Pattern Matching parse = (data: string): void => { // ... }; firstTerminalSpecificFunction = (): string => { // ... }; } class SecondTerminalParser { name = "SecondTerminalParser" as const; // # Same key, but different value parse = (data: string): void => { // ... }; secondTerminalSpecificFunction = (): number => { // ... }; } type Parser = FirstTerminalParser | SecondTerminalParser; const someFunction = (data: string, parser: Parser) => { parser.parse(data); switch (parser.name) { case "FirstTerminalParser": return parser.firstTerminalSpecificFunction(); case "SecondTerminalParser": return parser.secondTerminalSpecificFunction(); default: const arg: never = parser; // # This is a so-called Switch Safe Guard throw new Error(`Undefined ${parser}`) } };
This allows us to use specific functions of a specific implementation when needed.
At first it may not be very obvious, but my big advice: try to use Union Type and Pattern Matching instead of abstraction where you have a choice between several options, and then you will be able to understand the full power of this approach.
And here Union Type + Pattern Matching is a much more "explicit" solution, so it's better to lean towards it.
Reflection vs Code Generation
Reflection – identifying types and values at runtime.
Code Generation – creating code based on input data with predefined types.
For ease of understanding, let's consider an example with ORM:
Most common ORMs work based on reflection:
class UserTable extends BaseClass { id: string // Primary key of our table name: string // some field primaryKey = "id" // Metadata that will be needed for our ORM tableName = "user" // Metadata that will be needed for our ORM } class BaseClass { primaryKey: string save() { // This function most often first goes to the database to understand // whether such an entity exists and if yes, then does an UPDATE, // if not, then INSERT. But for this it needs to know the primary key. const res = this.dbConnection .from(this.tableName) // This is an example of reflection .where({ [this.primaryKey]: this[this.primaryKey] // Another example of reflection }) // ... the remaining code doesn't interest us much, so we'll skip it } }
And now an example with code generation:
// [tablename:User] type UserTable = { id: string // [type:primaryKey] name: string }
Imagine that we have a program that will take the description above and generate specific code from it:
const UserTable = { save: (user: User) => { const res = this.dbConnection .from("User") // As you can see, a specific value is used here .where({ id: user.id // And here }) // ... } }
That is, code generation will generate code with specific values and will not have to rely on checking the existence or identifying the type for the program to work.
Code generation requires special programs that know how to generate one or another code, and it's a shame that this is not a very common technique in Node.js, while in Golang it's one of the main techniques.
If you have the opportunity to use code generation, it's better to turn to it, it's a much more "explicit" solution.
Hooks vs Procedure & Composition
Examples of hooks can be .preSave or .preInsert, which are often found in ORM.
The main problem with hooks: to understand that they exist and are launched, you first need to know about their existence. And this is an extremely "implicit" behavior.
Now tell me, what will happen here:
const someFn = async (email: string) => { const user = { id: uuid.v4() email, } await User.insert(user) }
You might say "a user is created", but in fact, if we have some hook on insert, a billion additional actions can happen (I have sometimes seen notifications being sent from hooks).
Compare this with:
const someFn = async (email: string) => { const user = { id: uuid.v4() email, createdAt: new Date() // this should have been set in the hook } await User.insert(user) await Mailer.sendRegistrationEmail(user) // this too }
Furthermore, it's very difficult to control behavior written in hooks. I've seen people add special fields to entities to make decisions about one action or another in hooks based on them.
But what if we don't want to write this logic directly into the function? Then we can simply create a separate method that will handle this:
const prepareUserForCreate = (user: User) => { user.createdAt = new Date() } const saveUserAndSendEmail = async (user: User) => { const preparedUser = prepareUserForCreate(user) await User.insert(preparedUser) await Mailer.sendRegistrationEmail(preparedUser) } const someFn = async (email: string) => { const user = { id: uuid.v4() email, } await saveUserAndSendEmail(user) }
And such chains and compositions can be created in any form, which explicitly gives us an understanding of what logic works in which case.
Code execution on import
One of the worst practices in programming languages: executing code on import.
For example, in JS / TS, it's enough to simply write the operations of interest at the file level:
// /numArray.ts export const numArray = [] for(let i = 0; i < 60; i++) { numArray.push(i) }
When we import this file, the array will automatically fill up. And this is terrible. Because it is absolutely uncontrollable.
In any normal language, such an operation is prohibited or placed in a separate place (for example, in Go, there is a function init, which will run on import, but its use is considered bad practice and is only acceptable in very rare cases).
It's much better to do this:
// /numArray.ts export const numArray = [] export const fillArray = () => { for(let i = 0; i < 60; i++) { numArray.push(i) } }
Now you can control when and how it will be filled.
IoC Container vs Arguments
IoC Container takes away your control over the initialization and passing of dependencies.
Do you know how it will initialize your dependencies? In what sequence? What dependencies are required to start a specific service? Are you creating dependency recursions?
All this is removed into the abstraction of the IoC Container and becomes "implicit".
If, on the other hand, we manually initialize all the necessary dependencies and pass them in the arguments, we independently control the entire process of working with dependencies.
Yes, more arguments, yes, you will have to work with your hands, but again, in my opinion, it is much more important to be sure that the dependency you need has been initialized and passed where needed.
Logic in constructors
Constructor (and not just of classes) – is needed to bring data to some form.
I have repeatedly encountered a situation where developers created, for example, a connection to a database and connected right in the constructor:
class DBConnection { constructor(public host: string, public port: number) { this.connection = DB.connect(`${host}:${port}`) // For example like this } }
There's also a chance that a person won't do this because they can't do await before new, but the same applies to functions:
const DBConnection = async (public host: string, public port: number) => { const connection = await DB.connect(`${host}:${port}`) // For example like this return { host, port, connection, } }
Don't do that. The process of constructing an entity should be separated from the process of any other logic, for example, like this:
class DBConnection { constructor(public host: string, public port: number) {} connect() { this.connection = DB.connect(`${this.host}:${this.port}`) } } // ... const DBConnection = async (public host: string, public port: number) => { let connection: DB.Conncetion return { host, port, connect: () => { connection = await DB.connect(`${host}:${port}`) } } }
Framework vs Libs
There are many interpretations of the difference between these two concepts. I like this one the most:
Your code uses the library, and the framework uses your code
That is, if we expand on the example, the framework tells you the places and ways to write code, you follow these rules, and it produces magic using your code.
The library, on the other hand, we use ourselves within our code.
Here's an example of a framework:
import {app} from "framework"; app({ init: () => { // Framework will pre-initialize env, database, possibly code // from certain predefined folders. And inside will be your code. // ... } })
Here's an example of a library:
import {initConfig, initDb, initControllers} from "library" // In this case, we initialize everything separately ourselves // and put it together const main = () => { const config = initConfig() const db = initDb() const controllers = initControllers() const app = initApp({ config, db, controllers }) app.start() } main()
Actually, libraries from a design perspective are more manageable and have a smaller "black box", which is a huge advantage in terms of explicitness, so I advise when writing the next "framework" to still turn to code design as in libraries.
Yes, you will have to write more, learn more, set up more yourself, but explicitness is worth it.
Overloading
There are 2 types of overloading: function overloading and operator overloading.
Function overloading
function add(a:string, b:string):string; function add(a:number, b:number): number; function add(a: any, b:any): any { return a + b; }
Yes, this can of course be convenient in some cases, but I have never encountered a situation where function overloading couldn't be replaced with several separate methods:
function addStrings(a:string, b:string):string => { return a + b; } function addNumbers(a:number, b:number): number => { return a + b; }
Several additional functions are a much more obvious and explicit thing than with overloading.
Operator overloading
Operator overloading is available in some languages out of the box, for example, we can overload + so that it can merge arrays:
// This is a very conditional example Array.operator("+", (left: any[], right: any[]): any[] => { return [...left, ...right] }) // now use console.log([1,2,3] + [4,5,6]) // [1,2,3,4,5,6]
More common types of operator overloading are Iterator and getter & setter.
All these overloads change the standard behavior of the language, because of which a developer who has not read your custom overloads will get completely unexpected behavior for them.
Mutability vs Immutability
Disclaimer: I'm not suggesting you use immutability everywhere if it's not supported by your language out of the box. Working with immutability requires a certain design of code and architecture of thinking, which can unnecessarily complicate the codebase.
Tell me, what will the last console.log output:
const user = { id: 1, name: "David", age: 27 } someFunction(user) console.log(user) // What will this console.log output
If you understand how a mutable language works, you'll say you don't know, because any of the properties could have changed.
In such a simple form, you can already understand why mutability is an unclear approach. It requires you to study all the functions through which the entity passes to find out if there will be any changes to it.
And now let's look if we were following an immutable approach
const user = { id: 1, name: "David", age: 27 } // I won't apply any hacks to add immutability // we just assume that we never mutate state that comes from outside // so the someFunction function definitely won't change user someFunction(user) console.log(user) // Here 100% the same user will be output
And now we know for sure that nothing will happen to this entity. And if it does, we will get a new instance:
const user = { id: 1, name: "David", age: 27 } // I won't apply any hacks to add immutability // we just assume that we never mutate state that comes from outside // so the someFunction function definitely won't change user const updatedUser = someFunction(user) console.log(user) // Here 100% the same user will be output console.log(updatedUser) // But here there will already be some change
I won't go into a bunch of different details of immutability, but already the most basic example shows why this approach is much more explicit.
There are many more examples here and I will be adding them little by little (follow the news here)

And what's better?

The most difficult thing in a developer's work is to study and keep in mind the entire chain of data transformations in the program.
When you just wrote the code, all of its logic is "loaded" into your consciousness. You know where all the behavior chains start and what they use along the way.
But when another developer looks at it (or you, but after some time), they will have to build all these transformation chains in their head.
And here, first of all, any implicit behavior may go unnoticed, which subsequently causes bugs, and bugs cost time, money, and nerves.
And secondly, if they can find this implicit behavior, keeping it in mind and applying it to all cases is a very difficult process.
The more explicit the code, the easier it is to read, understand, change, and develop. And all these characteristics convert into the main metric - developer joy.

What's next

Ahead is the last chapter in the "Philosophy" section, in which we will throw everything... absolutely everything: