Today, we'll learn how to handle uncommon problems in everyday work. We'll explore solutions step by step, starting from basic problem-solving approaches to out-of-the-box solutions. Sometimes we might have limited options because, in reality, our world is often more complex than we imagine. But regardless of the reason, let's find ways to solve it.
If you notice that the topic title isn't mentioned much while reading, don't be surprised. The key point of this article isn't about the title, but rather the problem-solving concepts we'll present.
When working with poorly designed code, such as unrelated code being coupled together, it makes code reusability difficult.
1// Assumed to be setting up something important but unrelated2console.log(injectedVariable)This line runs when require/import even though it's not needed34const obj = {5field: 'interesting value'6}Value we want to use elsewhere78module.exports = obj
When using file-a.js
in other files, we need to set up various values before they can be used, like:
1global.injectedVariable = "Injected Utils, Helpers, ..."setup to be able to call file-a.js23const obj = require('./file-a.js')4console.log(obj.field)
Problems we'll encounter, like writing tests for file-a.js
without setup because it's not related to the test
1const obj = require('./file-a.js')ReferenceError: injectedVariable is not defined23test('correct value', () => {4expect(obj.field).toBe('interesting value')5})
When running file-a.test.js
, we'll get an error because we didn't set up injectedVariable
first, even though the obj.field
value we need doesn't depend on injectedVariable
When encountering this situation, some might notice this code lacks proper Separation of Concerns (SoC). This happen when unrelated code is forced to run together instead of running independently.
If we can refactor the code, we might change it to:
1+const setup = () => {2+// Assumed to be setting up something important but unrelated3console.log(injectedVariable)4+}Wrap in function to control when it runs56const obj = {7field: 'interesting value'8}910module.exports = { setup, obj }export to let the importing file choose when to run
1global.injectedVariable = "Injected Utils, Helpers, ..."23const { setup, obj } = require('./file-a.js')4+setup()Files that need it can call setup5console.log(obj.field)
1const { obj } = require('./file-a.js')Files that don't need it don't have to call setup23test('correct value', () => {4expect(obj.field).toBe('interesting value')5})
Just like that, our code has better separation of concerns and can run tests.
If you think this code can be written better, try it as homework. Because this isn't our goal today.
Let me share another perspective. What if refactoring is not an option we have, perhaps because:
file-a.js
file-b.js
is our core code structure that serves as foundation for hundreds of filesWe can't just refactor to access obj.field
because we don't know the full impact, which parts will break, or even who will help us fix hundreds of files and approve our PR.
Anyone facing this situation encounters the chicken-and-egg problem - wanting to refactor first but lacking test coverage, yet needing to refactor before writing more tests.
Generated by DALL·E 3. Prompt "A realistically rendered image displaying the philosophical conundrum known as the 'chicken and egg' problem."
So let's step back. Our current goal is to extract obj.field
. Besides importing normally like regular programming, what other approaches can we take?
Wouldn't it be nice if we could mimic human behavior - open VS Code, cmd+f
search for "field"
, select the value in ""
after :
, then cmd+c
? 🤔
Why not? We have Regex. Let's write some quick code:
1const fs = require('fs')23const codeAsText = fs.readFileSync('./file-a.js', { encoding: 'utf-8' })Read file-a.js as plain text4const regex = /field:\s*['"](.*?)['"]/;Use regex to find desired pattern5const match = input.match(regex);6const output = match[1]78console.log(output); // Output: interesting valueGet desired result
EZ? If our code isn't complex or follows standards, this could be a quick and effective method. But when our code can be written in various ways like:
1const obj = {2field:3'interesting value'new lines4}
1const obj = {2field : 'interesting value'extra white space and didn't run prettier3}
These codes run like the original, but writing regex to cover all cases wouldn't be easy or practical since comprehensive regex could become too hard to read. So we'll stop here with regex.
If we can't run the code directly, and reading as plain text is too complex for the time invested since we'd basically be writing our own parser from scratch, what middle ground options do we have?
Today I'm introducing AST. It might sound scary at first, which is true, but understanding just a little lets us use it effectively.
The cool thing is that instead of dealing with a wall of text, we can handle code in chunks related by their function. Each chunk becomes a Node by type - like code that runs a function without storing the result becomes an Expression Statement, while variable declarations become Variable Declaration nodes. We don't need to worry about whether lines end with semicolons or handle different syntaxes that produce the same result - we can focus on the functionality instead.
For example, in file-a.js
, when run through a parser it can be divided into 3 main chunks:
1console.log(injectedVariable)23const obj = {4field: 'interesting value'5}67module.exports = obj
From here, we have 2 main options:
obj.field
by traversing object AST nodes - similar to regex but using programming languageNeither approach is wrong - just choose what fits the situation.
For today, we'll take approach #2 - removing unwanted code and running what remains.
Our mental model now is wanting this result:
1-console.log(injectedVariable)2-3const obj = {4field: 'interesting value'5}67module.exports = obj
To write code achieving this, we'll follow these steps:
1const { parse } = require('acorn')lib for converting code to ast2const escodegen = require('escodegen')lib for converting ast back to code3const transformAST = requrie('./transformAST')assume this is function handling ast to get desired result4const codeAsText = fs.readFileSync('./file-a.js', { encoding: 'utf-8' })read code as string5const ast = parse(codeAsText)convert code to ast6const transformedAST = transformAST(ast)transform ast by removing/modifying as needed, in this case removing line 17const transformedCodeAsText = escodegen.generate(transformedAST)convert ast back to code8const obj = eval(transformedCodeAsText)run modified code like using require/import with eval9console.log(obj.field)PRINT: interesting value
With this, we have a script like modified-file-a.js
that can run without modifying or destroying original code.
Simple Example of
transformAST(ast)
js | transformAST.js
For those wondering where the OpenAPI Spec Generator went, we saved it as a more complex example of today's problem.
Let's see why this code is difficult to use elsewhere:
1-const { controller } = applicationapplication is injected, which isn't required but prevent generating API Schema2const { z } = require('zod')34const getPet = {5method: 'GET',6path: '/pet/:petID',7validation: {8param: {9petID: z.string().uuid(),10}11},Context for Gen API Schema12-controller: controller.getPetNot required but prevent generating API Schema13}1415const addPet = {16method: 'POST',17path: '/pet',18validation: {19body: {20petID: z.string().uuid().optional(),21name: z.string().uuid(),22status: z.enum(["available", "pending", "sold"]).optional(),23}24},Context for Gen API Schema25-controller: controller.addPetNot required but prevent generating API Schema26}2728module.exports = [getPet, addPet]
From the code, we can see several lines preventing us from easily require/import
this file.
To use it, we apply the same approach as before, modifying custom-extractor-script.js
especially the transformAST(ast)
function to remove unused nodes.
When successful, we should get this result:
1const { z } = require('zod')23const getPet = {4method: 'GET',5path: '/pet/:petID',6validation: {7param: {8petID: z.string().uuid(),9}10},11}1213const addPet = {14method: 'POST',15path: '/pet',16validation: {17body: {18petID: z.string().uuid().optional(),19name: z.string().uuid(),20status: z.enum(["available", "pending", "sold"]).optional(),21}22},23}2425module.exports = [getPet, addPet]
After running eval/import/require
with our newly created code, we can use these values to generate OpenAPI Specification. Yay!
This article might not be friendly for everyone, but hopefully it opens up new perspectives on handling code in different situations - whether viewing code as regular code, text, or as node and tree structures. By keeping an open mind to new perspectives, we can handle challenging problems without them being out of reach.
See you in the next article~