Some have leveled the criticism that "part 1" of this post is unnecessarily contriving a problem that doesn't really exist in "good code" -- if you really need to narrow a scope of some declarations in a function, then that function is too complex already and that bigger problem is what you need to fix.
Just to be extra clear: if a chunk of code is already reasonable to make a function, that's not the sort of code I'm talking about in either of these blog posts. If it's being called multiple times, or if it's completely independent and makes sense as its own logical chunk, make it a function. I'm talking about a different sort of code, a set of a few statements related to each other, that declare one or more variables, but which logically still belong inside another function. That's the context here.
OK, let's stop talking about this stuff abstractly.
I'm going to take the React code base as a concrete, real-world, non-contrived example, since it's popular and written by ostensibly pretty smart and good JS developers.
I literally just clicked through their massive code base to the first file I could find that had any substantial code in it, and this was the file, highlighting lines 805-851, the function updateProperties(..)
.
I'm pasting an abbreviated version of it here so I can illustrate:
export function updateProperties(domElement: Element, updatePayload: Array<any>, tag: string, lastRawProps: Object, nextRawProps: Object, ): void {
if (
tag === 'input' &&
nextRawProps.type === 'radio' &&
nextRawProps.name != null
) {
ReactDOMInputUpdateChecked(domElement, nextRawProps);
}
const wasCustomComponentTag = isCustomComponent(tag, lastRawProps);
const isCustomComponentTag = isCustomComponent(tag, nextRawProps);
// Apply the diff.
updateDOMProperties(
domElement,
updatePayload,
wasCustomComponentTag,
isCustomComponentTag,
);
switch (tag) {
/* .. */
}
}
I want to call your attention to the two const
declarations in the middle of the function, for wasCustomComponentTag
and isCustomComponentTag
. Taking a look at the code, those two variables are only used on the very next line, the updateDOMProperties(..)
call. They're not used in any of the subsequent (omitted) ~20 lines of switch
code.
So... why should those two variables exist in, and be visible to, the function's scope? I argue that this is unnecessary because they aren't needed. Further, I argue it's additional mental overhead a reader has to juggle, because while reading the rest of the function, they have to wonder if either variable will be used again. I suppose the React devs don't care about either, since they didn't bother to narrow the scope?
And perhaps you agree with them?
But, going on the reasoning I firmly hold, that those variables are more visible/exposed than they strictly need to be (see "Principle of Least Privilege" aka "Principle of Least Exposure"), it's at least rational to ask whether that code could/should be a little more readable, and a little more robust/maintainable, if the two declarations were narrowed in scope.
How should we consider doing so? I guess many of you would say "stick those 3 statements in their own function".
OK, but then... two important questions:
-
What should that function be called? I suggest we call it
applyTheDiff(..)
, since that's exactly what the code comment there describes as the purpose of that code:function applyTheDiff() { const wasCustomComponentTag = isCustomComponent(tag, lastRawProps); const isCustomComponentTag = isCustomComponent(tag, nextRawProps); // Apply the diff. updateDOMProperties( domElement, updatePayload, wasCustomComponentTag, isCustomComponentTag, ); }
OK, great.
-
But now, should
applyTheDiff(..)
be located as an inner child function ofupdateProperties(..)
?If so, now
updateProperties(..)
is a bit more complicated to read, in that it has an inner function declaration. And also, that inner function is now using outer-scoped variables (parameters onupdateProperties(..)
) ofdomElement
andupdatePayload
. Takes more mental effort for someone to read that code, now that they need to scan up the lexical scope tree to find those identifiers to see where they come from. Not a lot, but a little extra.Instead, we could manually pass
domElement
andupdatePayload
as arguments into the innerapplyTheDiff(..)
function, making it clearer where those identifiers come from, but now that's a little more verbose, and a refactoring hazard if those need to be renamed.function applyTheDiff(domElement,updatePayload) { const wasCustomComponentTag = isCustomComponent(tag, lastRawProps); const isCustomComponentTag = isCustomComponent(tag, nextRawProps); // Apply the diff. updateDOMProperties( domElement, updatePayload, wasCustomComponentTag, isCustomComponentTag, ); }
If we instead move
applyTheDiff(..)
up one level as a sibling ofupdateProperties(..)
, now we have a different issue:applyTheDiff(..)
looks to any reader of the of the code like it can be called directly, just asupdateProperties(..)
can be called. And the lexical nameapplyTheDiff
(or whatever we pick) could be a future collision hazard with that level of scope, perhaps when someone later wants to define a function of that name in that same scope.Moreover, by raising
applyTheDiff(..)
outside of the scope where it's currently only used (and thus only called once), we have just violated the same "Principle of Least Privilege" that we were trying to avoid in the first place (by placing the twoconst
declarations andupdateDOMProperties(..)
call inside theapplyTheDiff(..)
function)!We solved one problem and created another in its place. Oops.
Functions used for this single-use purpose will always have this frustrating set of ironic contradictions/questions.
Let's instead look at that real code example from React with the block-of-scope option in mind:
export function updateProperties(domElement: Element, updatePayload: Array<any>, tag: string, lastRawProps: Object, nextRawProps: Object, ): void {
if (
tag === 'input' &&
nextRawProps.type === 'radio' &&
nextRawProps.name != null
) {
ReactDOMInputUpdateChecked(domElement, nextRawProps);
}
applyTheDiff: {
const wasCustomComponentTag = isCustomComponent(tag, lastRawProps);
const isCustomComponentTag = isCustomComponent(tag, nextRawProps);
updateDOMProperties(
domElement,
updatePayload,
wasCustomComponentTag,
isCustomComponentTag,
);
}
switch (tag) {
/* .. */
}
}
This approach has none of the frustrating questions to juggle that the function approach presents.
Side Note: If you really hate the applyTheDiff
block label there, omit it and use a code comment. Makes no different to my larger point.
The big point is, the { .. }
block is a better tool for localizing the wasCustomComponentTag
and isCustomComponentTag
declarations, which had no reason to be exposed to the rest of that updateProperties(..)
function.
To repeat/summarize myself from the previous post, this scope-block approach also DOES NOT SUFFER any of these other complexities:
-
(Slight) performance overhead of calling a single-use function (in case the JS compiler doesn't inline it)
-
Increasing the call-stack in case of debugging an exception
-
Altering meaning/behavior of a
this
,arguments
,new.target
,super
,return
,await
,yield
,break
, orcontinue
by wrapping a function boundary around it
The only tangible downside I've seen leveled against this idea is: "it's not common in JS".
It's been common in other languages (C++, Java, etc) for decades. We in JS land should consider adopting it as a best practice from those other languages instead of saying we know better and "functions everything". If we do adopt it, and it catches on wider, the "it's uncommon/weird" argument fades away.
In fact, most "best practices" in some language come from that same adoption process from another language. This has happened for JS many times before, and it could happen again here.
Why such closed-minds on these things? Why not be willing to creatively apply valid JS to address problems differently and maybe even better?
👍
Personally, I’m not a fan of labels - it looks weird, just like a function definition and one may wonder whether the code inside actually executes or need to be called by the label like a function? That may be too much :) On the other hand it is another overlooked and in some way cool JS feature 😎
IMO “block for local scope” is great way to write long and boring procedures in a single function with preservation of readability and mental/references-safety tidiness. It saves a lot of time (and sometimes complexity) comparing to the somewhat ancient way of writing separate functions. ;)
——
But… Another tiny downside worth considering in performance-sensible parts of code is that creating such a “visibility guard” block scope may use some CPU cycles and a segment of memory internally to create a closure/scope and then clearing it after leaving the scope by the code executor. But in 99% of potential usages it won’t matter.