\') || SELF_CLOSING_TAGS.has(tagName);\\n const tagAttributeString = str.\\n replace(new RegExp(`^</?${tagName}`), \'\').\\n replace(/\\\\/?>/, \'\').\\n trim() || null;\\n\\n tokens.push({\\n type: \'tag\',\\n tagName,\\n opening: !isClosingTag || isSelfClosingTag,\\n closing: isClosingTag || isSelfClosingTag,\\n tagAttributeString\\n });\\n\\n buffer = \'\';\\n};\\n I\'m not going to dive deeper into attribute handling, as this is almost a tokenizer of its own, but we\'ll simply store them as a string for now. Additionally, my self-closing tags list is very incomplete, but it\'ll do for now.
While this might look a little intimidating at first, there\'s not a whole lot going on in this function. We\'re simply performing the steps I outlined above, then, if all goes well, we\'re adding the token to the tokens
array and emptying the buffer.
We\'re now ready to tokenize an HTML string. The structure of the tokenizer is very similar to the previous article, so I won\'t bother you with all the minute details. The only point I would like to focus on is the actual character processing loop.
\\nIn this loop, we\'re simply adding characters to the buffer until we encounter either a <
or a >
. When we do, we flush the buffer and continue. The one thing that\'s of note is that we handle these two characters differently. When we encounter a <
, we flush the buffer, then add the character, whereas when we encounter a >
, we add the character, then flush the buffer. This allows us to parse both tags and text nodes correctly.
Let\'s take a look at the full tokenization code:
\\nconst SELF_CLOSING_TAGS = new Set([\\n \'br\', \'img\', \'input\', \'meta\', \'hr\', \'link\'\\n]);\\n\\nconst tokenizeHtml = str => {\\n const tokens = [];\\n let buffer = \'\';\\n\\n const processTagToken = str => {\\n if (!str.match(/^<[^<>]+>$/))\\n throw new Error(`${str} is not a valid HTML tag`);\\n\\n const tagName = str.match(/^<\\\\/?([^<>/ ]+)/)[1];\\n const isClosingTag = str.startsWith(\'</\');\\n const isSelfClosingTag =\\n str.endsWith(\'/>\') || SELF_CLOSING_TAGS.has(tagName);\\n const tagAttributeString = str.\\n replace(new RegExp(`^</?${tagName}`), \'\').\\n replace(/\\\\/?>/, \'\').\\n trim() || null;\\n\\n tokens.push({\\n type: \'tag\',\\n tagName,\\n opening: !isClosingTag || isSelfClosingTag,\\n closing: isClosingTag || isSelfClosingTag,\\n tagAttributeString\\n });\\n\\n buffer = \'\';\\n };\\n\\n const processTextToken = str => {\\n tokens.push({ type: \'text\', value: str });\\n buffer = \'\';\\n };\\n\\n // Flush the buffer and process the tokens\\n const flushBuffer = () => {\\n if (!buffer.length) return;\\n\\n const value = buffer.trim();\\n if (value.startsWith(\'<\') || value.endsWith(\'>\'))\\n processTagToken(value);\\n else\\n processTextToken(value);\\n };\\n\\n // Tokenize the input string\\n [...str].forEach(char => {\\n // If we encounter the opening tag, flush the buffer\\n if (char === \'<\') flushBuffer();\\n\\n // Add the character to the buffer\\n buffer += char;\\n\\n // If we encounter the closing tag, flush the buffer\\n if (char === \'>\') flushBuffer();\\n });\\n\\n // Flush any remaining buffer\\n flushBuffer();\\n\\n return tokens;\\n};\\n
Let\'s see it in action, shall we?
\\nconst tokens = tokenizeHtml(\\n \'<div class=\\"container\\"><p>Hello, <strong>world</strong>!<br/></p></div>\'\\n);\\n// [\\n// {\\n// type: \'tag\', tagName: \'div\',\\n// opening: true, closing: false,\\n// tagAttributeString: \'class=\\"container\\"\'\\n// },\\n// {\\n// type: \'tag\', tagName: \'p\',\\n// opening: true, closing: false,\\n// tagAttributeString: null\\n// },\\n// { type: \'text\', value: \'Hello,\' },\\n// {\\n// type: \'tag\', tagName: \'strong\',\\n// opening: true, closing: false,\\n// tagAttributeString: null\\n// },\\n// { type: \'text\', value: \'world\' },\\n// {\\n// type: \'tag\', tagName: \'strong\',\\n// opening: false, closing: true,\\n// tagAttributeString: null\\n// },\\n// { type: \'text\', value: \'!\' },\\n// {\\n// type: \'tag\',\\n// tagName: \'br\',\\n// opening: true, closing: true,\\n// tagAttributeString: null\\n// },\\n// {\\n// type: \'tag\', tagName: \'p\',\\n// opening: false, closing: true,\\n// tagAttributeString: null\\n// },\\n// {\\n// type: \'tag\', tagName: \'div\',\\n// opening: false, closing: true,\\n// tagAttributeString: null\\n// }\\n// ]\\n
In the previous article on bracket pair matching, we used a simple stack-based approach to match bracket pairs. This scenario is no different, only we need to work with tokens, instead of a raw string and its characters.
\\nThe idea is rather simple. We skip non-tag tokens entirely and we keep track of encountered tags. When we encounter an opening tag, we push its tagName
and index to the stack. When we encounter a closing tag, we pop the last tag from the stack and check if they match. If they don\'t, we throw an error. If they do, we continue. If we reach the end of the tokens and the stack is not empty, we also throw an error.
const findMatchingTags = tokens => {\\n const { pairs, stack } = tokens.reduce(\\n ({ pairs, stack }, token, i) => {\\n if (token.type === \'tag\') {\\n if (token.opening && !token.closing) {\\n stack.push({ index: i, tagName: token.tagName });\\n } else if (token.closing && !token.opening) {\\n const {index: openingIndex, tagName} = stack.pop();\\n if (tagName !== token.tagName)\\n throw new Error(`Mismatched tags: ${tagName} and ${token.tagName}`);\\n pairs.set(openingIndex, i);\\n pairs.set(i, openingIndex);\\n }\\n }\\n return { pairs, stack };\\n },\\n { pairs: new Map(), stack: [] }\\n );\\n\\n if (stack.length)\\n throw new Error(\'Unmatched HTML tags\');\\n\\n return pairs;\\n};\\n
This isn\'t all that different from the last implementation, only we\'re working with tokens instead of characters. Let\'s see it in action:
\\n// Given the tokens from the previous example\\nconst pairs = findMatchingTags(tokens);\\n// Map {\\n// 0 => 9, 9 => 0,\\n// 1 => 8, 8 => 1,\\n// 3 => 5, 5 => 3\\n// }\\n
Putting the previous two pieces together, we can create a simple HTML validator that tokenizes the input string, matches the tag pairs and throws an error if the tags are mismatched or unmatched or if the input string is not a valid HTML string.
\\nconst validateHtml = str => {\\n const tokens = tokenizeHtml(str);\\n const matchingTags = findMatchingTags(tokens);\\n return { tokens, matchingTags };\\n};\\n
And that\'s basically our simple HTML validator done! Of course, there are a ton more things one can and should take care of when parsing HTML, but this is a good starting point for understanding the basics.
\\nI hope this article has really driven home the mentality of tokenization and how it can be used in real-world scenarios to break down complex strings into more manageable pieces. See you in the next one!
Last updated: February 21, 2025 View on GitHub
","description":"I\'ve been down the tokenization rabbit hole for a little while now, if it wasn\'t obvious from the previous articles on bracket pair matching and math expression tokenization. This time, I wanted to try something a little more complex, but still simple enough to be done in a…","guid":"https://www.30secondsofcode.org/js/s/simple-html-tokenization-validation","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-02-20T16:00:00.301Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/jars-on-shelf-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/jars-on-shelf-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," String "],"attachments":null,"extra":null,"language":null},{"title":"How can I calculate the diff between two strings in JavaScript?","url":"https://www.30secondsofcode.org/js/s/myers-diff-algorithm","content":"Have you ever wondered how Git\'s diff algorithm works? From what I\'ve read, it\'s based on the Myers diff algorithm, which itself is based on solving the longest common subsequence problem, which I\'ve covered in a previous article. So, let\'s take a look at how we can implement this algorithm in JavaScript!
\\nI highly recommend you read the article on the longest common subsequence before diving into this one, as it will help you understand the underlying concepts better.
\\nI find that examples always help clarify things, so let\'s start with one. Assuming two sequences, a
and b
, we want to find the minimum number of edits required to convert a
into b
. These edits can be either insertions, deletions, or replacements. For this example, we\'ll start with two strings, but we\'ll later build a more general solution that can handle any sequence.
const a = \'kitten\';\\nconst b = \'sitting\';\\n
The expected output for this example should be something like this:
\\nconst diff = [\\n { value: \'k\', operation: \'delete\' },\\n { value: \'s\', operation: \'insert\' },\\n { value: \'i\', operation: \'equal\' },\\n { value: \'t\', operation: \'equal\' },\\n { value: \'t\', operation: \'equal\' },\\n { value: \'e\', operation: \'delete\' },\\n { value: \'i\', operation: \'insert\' },\\n { value: \'n\', operation: \'equal\' },\\n { value: \'g\', operation: \'insert\' }\\n];\\n
As you can see, we have a list of objects, each containing a value
and an operation
. The value
is the character from the original sequence, and the operation
is the type of edit required to convert the original sequence into the new one. If there is no edit required, the operation is equal
. We also make sure that delete
operations take precedence over insert
operations. This makes the output more readable and easier to understand.
Same as the previous problem, we\'ll use dynamic programming. The approach is very much similar to LCS, but with some tweaks. Instead of tiring you with all the details, I\'ll just explain the algorithm\'s steps, given two sequences, a
and b
, of lengths m
and n
:
(m + 1) x (n + 1)
matrix, dp
. Each column represents a character of a
, and each row represents a character of b
.0
to n
and the first column with the numbers 0
to m
, representing the number of deletions and insertions required to convert an empty string into the corresponding string.a[i - 1]
equals b[j - 1]
, set dp[i][j] = dp[i - 1][j - 1]
(no edit required).dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
(minimum of the three operations).i > 0
and j > 0
and a[i - 1]
equals b[j - 1]
, add an object with value: a[i - 1]
and operation: \'equal\'
to the diffs
array.i > 0
and either j === 0
or dp[i][j] === dp[i - 1][j] + 1
, add an object with value: a[i - 1]
and operation: \'delete\'
to the diffs
array (deletion takes precedence over insertion).value: b[j - 1]
and operation: \'insert\'
to the diffs
array.diffs
array when both i
and j
are 0
.There are various optimizations that can be made to this algorithm, but this article is meant primarily as a learning resource. Therefore, I\'ll keep things nice and simple and leave these optimizations out for now.
\\nAs you can probably tell, this is very similar to the LCS algorithm, but with a few tweaks to handle deletions and insertions. Let\'s implement this in JavaScript. We\'ll again make sure it can handle both strings and arrays as input.
\\nconst myersDiff = (a, b) => {\\n // Find the lengths of the two sequences\\n const m = a.length, n = b.length;\\n\\n // Create a 2D array to store the differences\\n const dp = Array(m + 1).fill(0).map(() => Array(n + 1).fill(0));\\n\\n // Initialize the first row and column\\n dp[0] = dp[0].map((_, j) => j);\\n dp.forEach((row, i) => row[0] = i);\\n\\n // Fill in the rest of the 2D array\\n for (let i = 1; i <= m; i++) {\\n for (let j = 1; j <= n; j++) {\\n if (a[i - 1] === b[j - 1]) {\\n dp[i][j] = dp[i - 1][j - 1];\\n } else {\\n dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);\\n }\\n }\\n }\\n\\n // Reconstruct the differences\\n let i = m, j = n, diffs = [];\\n\\n while (i > 0 || j > 0) {\\n if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {\\n diffs.unshift({ value: a[i - 1], operation: \'equal\' });\\n i--;\\n j--;\\n } else if (i > 0 && (j === 0 || dp[i][j] === dp[i - 1][j] + 1)) {\\n diffs.unshift({ value: a[i - 1], operation: \'delete\' });\\n i--;\\n } else {\\n diffs.unshift({ value: b[j - 1], operation: \'insert\' });\\n j--;\\n }\\n }\\n\\n return diffs;\\n}\\n\\nmyersDiff(\'kitten\', \'sitting\');\\n// [\\n// { value: \'k\', operation: \'delete\' },\\n// { value: \'s\', operation: \'insert\' },\\n// { value: \'i\', operation: \'equal\' },\\n// { value: \'t\', operation: \'equal\' },\\n// { value: \'t\', operation: \'equal\' },\\n// { value: \'e\', operation: \'delete\' },\\n// { value: \'i\', operation: \'insert\' },\\n// { value: \'n\', operation: \'equal\' },\\n// { value: \'g\', operation: \'insert\' }\\n// ]\\n
This is nice and all, but how would one go about visualizing it the way Git does? It\'s simple, actually! All we need to do is iterate over the diffs
array and print the values and a prefix based on the operation. While we\'re at it, we may as well add the number of additions and deletions required as an overview at the beginning of our output.
const visualizeDiff = (diffs) => {\\n const { insertions, deletions, output } = diffs.reduce(\\n (acc, { value, operation }) => {\\n if (operation === \'insert\') {\\n acc.insertions++;\\n acc.output.push(`+${value}`);\\n } else if (operation === \'delete\') {\\n acc.deletions++;\\n acc.output.push(`-${value}`);\\n } else {\\n acc.output.push(` ${value} `);\\n }\\n return acc;\\n }, { insertions: 0, deletions: 0, output: [] });\\n\\n const insertionsCount = insertions > 0 ? `${insertions} insertions(+), ` : \'\';\\n const deletionsCount = deletions > 0 ? `${deletions} deletions(-)` : \'\';\\n\\n return `${insertionsCount}${deletionsCount}\\\\n\\\\n${output.join(\'\\\\n\')}`;\\n};\\n
We\'ll explore a more complex example for this, using arrays of strings, instead of just strings, so that it looks a little more interesting.
\\nconst fileA = [\\n \'20 bottles of beer on the wall\',\\n \'20 bottles of beer\',\\n \'Take one down, pass it around\',\\n \'19 bottles of beer on the wall\'\\n];\\n\\nconst fileB = [\\n \'19 bottles of beer on the wall\',\\n \'19 bottles of beer\',\\n \'Take one down, pass it around\',\\n \'18 bottles of beer on the wall\'\\n];\\n\\nconst diffs = myersDiff(fileA, fileB);\\nvisualizeDiff(diffs);\\n// 3 insertions(+), 3 deletions(-)\\n//\\n// -20 bottles of beer on the wall\\n// +19 bottles of beer on the wall\\n// -20 bottles of beer\\n// +19 bottles of beer\\n// Take one down, pass it around\\n// -19 bottles of beer on the wall\\n// +18 bottles of beer on the wall\\n
Looks a lot like a Git diff, doesn\'t it? You can definitely make it even better by adding colors or skipping over long unchanged subsequences, but I\'ll leave that up to you.
\\nAnd there you have it! You can now find the differences between two sequences using the Myers diff algorithm and visualize them in a readable format. If you want to join the community discussion on this article, as well as the LCS one, use the link below to jump into the GitHub discussion!
Last updated: February 12, 2025 View on GitHub ·\\nJoin the discussion
","description":"Have you ever wondered how Git\'s diff algorithm works? From what I\'ve read, it\'s based on the Myers diff algorithm, which itself is based on solving the longest common subsequence problem, which I\'ve covered in a previous article. So, let\'s take a look at how we can implement…","guid":"https://www.30secondsofcode.org/js/s/myers-diff-algorithm","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-02-11T16:00:00.572Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/peaches-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/peaches-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," String "],"attachments":null,"extra":null,"language":null},{"title":"Find matching bracket pairs in a string with JavaScript","url":"https://www.30secondsofcode.org/js/s/find-matching-bracket-pairs","content":"I was recently honing my skills over at CodeWars and I came across an Esolang interpreter kata, that, ultimately, required me to find matching bracket pairs in a string. I\'ll spare you the details of the problem for now (maybe I\'ll write about it in the future), but I thought I\'d share the bracket matching solution with you, as I\'ve found it to be a recurring problem when building parsers or solving similar problems.
\\nI\'m sure that searching for this problem will yield lots of results, mostly variations of the same sort of solution, many of which contain no comments or explanations. I\'ll try to explain the solution as best as I can, starting from simple parentheses matching and then moving on to more complex bracket pairs.
\\nGiven a simple string with parentheses, like ((a + b))
, we want to find the matching pairs of parentheses. In this case, the pairs are (0, 8)
and (1, 7)
. The first pair is the outermost parentheses, and the second pair is the innermost parentheses.
Looking closely, we can identify the pattern we\'re looking for. If we come across an opening parenthesis, we have to store its index. When we find a closing parenthesis, we can pair it with the last opening parenthesis we found. This way, we can find the matching pairs in the given string.
\\nIf, however, we find a closing parenthesis without a matching opening parenthesis, we can safely assume that the string is invalid. Similarly, if we find an opening parenthesis without a matching closing parenthesis, the string is also invalid.
\\nTo solve this problem, we can use a stack-based approach. As we\'ll only need its push()
and pop()
methods, we can use a simple array to represent the stack. Let\'s see how we can implement this in JavaScript:
const findMatchingParentheses = str => {\\n const { stack, pairs } = [...str].reduce(\\n ({ pairs, stack }, char, i) => {\\n if (char === \'(\') stack.push(i);\\n else if (char === \')\') {\\n if (stack.length === 0) throw new Error(\'Invalid string\');\\n pairs.push([stack.pop(), i]);\\n }\\n return { pairs, stack };\\n },\\n { pairs: [], stack: [] }\\n );\\n\\n if (stack.length > 0) throw new Error(\'Invalid string\');\\n\\n return pairs;\\n};\\n\\nfindMatchingParentheses(\'a + b\'); // []\\nfindMatchingParentheses(\'(a + b)\'); // [[0, 6]]\\nfindMatchingParentheses(\'((a + b))\'); // [[1, 7], [0, 8]]\\nfindMatchingParentheses(\'((a + b)\'); // Error: Invalid string\\nfindMatchingParentheses(\'a + b)\'); // Error: Invalid string\\n
If you want to learn more about stacks, check out the article on the Stack data structure and how it can be implemented in JavaScript.
\\nNow that we\'ve seen how to find matching parentheses, we can extend the solution to matching bracket pairs. We\'ll consider the following bracket pairs: ()
, []
, and {}
.
The approach is similar to the one we used for parentheses. The main difference is that we\'ll use an object as a dictionary of opening and closing brackets. This way, we can easily check if a character is an opening or closing bracket.
\\nSame as before, we\'ll then push the index of the opening bracket to the stack. When we find a closing bracket, we\'ll pair it with the last opening bracket we found. If we find a closing bracket without a matching opening bracket, or vice versa, we\'ll throw an error.
\\nconst findMatchingBrackets = str => {\\n const bracketMap = {\\n \'(\': \')\',\\n \'[\': \']\',\\n \'{\': \'}\',\\n };\\n const bracketOpenings = Object.keys(bracketMap);\\n const bracketClosings = Object.values(bracketMap);\\n\\n const { stack, pairs } = [...str].reduce(\\n ({ pairs, stack }, char, i) => {\\n if (bracketOpenings.includes(char)) stack.push(i);\\n else if (bracketClosings.includes(char)) {\\n if (stack.length === 0) throw new Error(\'Invalid string\');\\n const openingIndex = stack.pop();\\n if (bracketMap[str[openingIndex]] !== char)\\n throw new Error(\'Invalid string\');\\n pairs.push([openingIndex, i]);\\n }\\n return { pairs, stack };\\n },\\n { pairs: [], stack: [] }\\n );\\n\\n if (stack.length > 0) throw new Error(\'Invalid string\');\\n\\n return pairs;\\n};\\n\\nfindMatchingBrackets(\'a + b\'); // []\\nfindMatchingBrackets(\'(a + b)\'); // [[0, 6]]\\nfindMatchingBrackets(\'((a + b))\'); // [[1, 7], [0, 8]]\\nfindMatchingBrackets(\'([a] + b)\'); // [[1, 3], [0, 8]]\\nfindMatchingBrackets(\'([{a} + b])\'); // [[2, 4], [1, 9], [0, 10]]\\nfindMatchingBrackets(\'((a + b)\'); // Error: Invalid string\\nfindMatchingBrackets(\'a + b)\'); // Error: Invalid string\\nfindMatchingBrackets(\'([a + b}\'); // Error: Invalid string\\n
Map
In many of the use cases I\'ve come across, a Map
is way easier to work with as a result, instead of an array of arrays. The simple fact of the matter is, in most scenarios, you\'ll want to retrieve the matching bracket index, given an opening or closing bracket index. This is where a Map
comes in handy.
Instead of storing pairs of indexes in an array, we can store them in a Map
. But, given that we may want to retrieve the matching index given an opening or closing bracket index, we\'ll need to store both sides of the pair in the Map
at the same time. Here\'s how using a Map
would look like:
const findMatchingBrackets = str => {\\n const bracketMap = {\\n \'(\': \')\',\\n \'[\': \']\',\\n \'{\': \'}\',\\n };\\n const bracketOpenings = Object.keys(bracketMap);\\n const bracketClosings = Object.values(bracketMap);\\n\\n const { stack, pairs } = [...str].reduce(\\n ({ pairs, stack }, char, i) => {\\n if (bracketOpenings.includes(char)) stack.push(i);\\n else if (bracketClosings.includes(char)) {\\n if (stack.length === 0) throw new Error(\'Invalid string\');\\n const openingIndex = stack.pop();\\n if (bracketMap[str[openingIndex]] !== char)\\n throw new Error(\'Invalid string\');\\n pairs.set(openingIndex, i);\\n pairs.set(i, openingIndex);\\n }\\n return { pairs, stack };\\n },\\n { pairs: new Map(), stack: [] }\\n );\\n\\n if (stack.length > 0) throw new Error(\'Invalid string\');\\n\\n return pairs;\\n};\\n\\nconst str = \'([{a} + b])\';\\nconst pairs = findMatchingBrackets(str);\\n\\n[...str].forEach((char, i) => {\\n if (pairs.has(i)) {\\n const matchingIndex = pairs.get(i);\\n const matchingChar = str[pairs.get(i)];\\n console.log(\\n `Bracket \\"${char}\\" at index ${i} matches with bracket \\"${matchingChar}\\" at index ${matchingIndex}`\\n );\\n }\\n});\\n// Bracket \\"(\\" at index 0 matches with bracket \\")\\" at index 10\\n// Bracket \\"[\\" at index 1 matches with bracket \\"]\\" at index 9\\n// Bracket \\"{\\" at index 2 matches with bracket \\"}\\" at index 4\\n// Bracket \\"}\\" at index 4 matches with bracket \\"{\\" at index 2\\n// Bracket \\"]\\" at index 9 matches with bracket \\"[\\" at index 1\\n// Bracket \\")\\" at index 10 matches with bracket \\"(\\" at index 0\\n
The solutions we\'ve seen so far handles parentheses and brackets. But, what if I told you it can handle far more complex inputs, such as HTML or XML tags? The solution can be generalized to handle any type of bracket pair, as long as you provide the rules for the opening and closing brackets and how they match.
\\nLet\'s expand our solution to accept the rules for the opening and closing brackets as arguments. This way, we can handle different types of bracket pairs, such as <
and >
for HTML tags. Here\'s how you can do it:
const findMatchingBrackets = bracketMap => {\\n const bracketOpenings = Object.keys(bracketMap);\\n const bracketClosings = Object.values(bracketMap);\\n\\n return str => {\\n const { stack, pairs } = [...str].reduce(\\n ({ pairs, stack }, char, i) => {\\n if (bracketOpenings.includes(char)) stack.push(i);\\n else if (bracketClosings.includes(char)) {\\n if (stack.length === 0) throw new Error(\'Invalid string\');\\n const openingIndex = stack.pop();\\n if (bracketMap[str[openingIndex]] !== char)\\n throw new Error(\'Invalid string\');\\n pairs.set(openingIndex, i);\\n pairs.set(i, openingIndex);\\n }\\n return { pairs, stack };\\n },\\n { pairs: new Map(), stack: [] }\\n );\\n\\n if (stack.length > 0) throw new Error(\'Invalid string\');\\n\\n return pairs;\\n };\\n};\\n\\nconst findBracketPairs =\\n findMatchingBrackets({ \'(\': \')\', \'[\': \']\', \'{\': \'}\' });\\nconst str = \'([{a} + b])\';\\nconst pairs = findBracketPairs(str);\\n// Map(6) {\\n// 0 => 10, 1 => 9, 2 => 4,\\n// 4 => 2, 9 => 1, 10 => 0\\n//}\\n\\nconst findHtmlPairs = findMatchingBrackets({ \'<\': \'>\' });\\nconst htmlStr = \'<div><p>Hello, world!</p></div>\';\\nconst htmlPairs = findHtmlPairs(htmlStr);\\n// Map(8) {\\n// 0 => 4, 5 => 7, 21 => 24, 25 => 30,\\n// 30 => 25, 24 => 21, 7 => 5, 4 => 0\\n// }\\n
I\'m using currying to create a function that accepts the bracket rules and returns a function that accepts the string to find the matching bracket pairs. This way, you can easily reuse the function with different inputs, given the same bracket rules.
\\nAs you can see, solving the matching bracket pairs problem is straightforward, using a stack-based approach. While this solution is fairly simple, it can easily be expanded upon for more complex tasks, such as incorporating a tokenizer to parse multi-character tokens (e.g. full HTML tags).
\\nI hope you found this article helpful and that you can use this solution in your projects or coding katas. If you have any questions or suggestions, feel free to join the discussion below. I\'d love to hear your thoughts on this topic!
Last updated: February 7, 2025 View on GitHub ·\\nJoin the discussion
","description":"I was recently honing my skills over at CodeWars and I came across an Esolang interpreter kata, that, ultimately, required me to find matching bracket pairs in a string. I\'ll spare you the details of the problem for now (maybe I\'ll write about it in the future), but I thought I…","guid":"https://www.30secondsofcode.org/js/s/find-matching-bracket-pairs","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-02-06T16:00:00.496Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/invention-shack-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/invention-shack-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," String "],"attachments":null,"extra":null,"language":null},{"title":"How can I find the longest common subsequence of two strings in JavaScript?","url":"https://www.30secondsofcode.org/js/s/longest-common-subsequence","content":"The longest common subsequence (LCS) is the longest subsequence common to all given sequences. It is not the same as the longest common substring, which must occupy consecutive positions within the original sequences. The LCS problem can be easily solved using dynamic programming.
\\nSolving this problem with dynamic programming is not the most efficient way to find the LCS. However, dynamic programming is the most straightforward way to understand the problem. If you\'re looking for a more efficient solution, you can search for it online.
\\nBefore we jump into the code, let\'s look at an example to understand the problem better. Consider the following two strings:
\\nconst string1 = \'AGGTAB\';\\nconst string2 = \'GXTXAYB\';\\n
The longest common subsequence of these two strings is \'GTAB\'
, which has a length of 4. But how did we arrive to this conclusion? What is trivial for human intuition may not be so for computer code. This is where dynamic programming comes into play.
Instead of trying to solve the problem directly, we can break it down into smaller subproblems. We can create a 2D table to store the lengths of the longest common subsequences of the prefixes of the two strings. Then, using the values in this table, we can build up the solution to the original problem.
\\nHere\'s a step-by-step guide to finding the LCS of these two strings using this approach. Use the buttons below the table to replay each step and move forward or backward.
\\n\\n\\n\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
G | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
G | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
T | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
A | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
B | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
G | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
T | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
A | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
B | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
T | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
A | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
B | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
T | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
A | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
B | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
T | \\n0 | \\n1 | \\n1 | \\n2 | \\n2 | \\n2 | \\n2 | \\n2 | \\n
A | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
B | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
T | \\n0 | \\n1 | \\n1 | \\n2 | \\n2 | \\n2 | \\n2 | \\n2 | \\n
A | \\n0 | \\n1 | \\n1 | \\n2 | \\n2 | \\n3 | \\n3 | \\n3 | \\n
B | \\n0 | \\n\\n | \\n | \\n | \\n | \\n | \\n | \\n |
\\n | ε | \\nG | \\nX | \\nT | \\nX | \\nA | \\nY | \\nB | \\n
---|---|---|---|---|---|---|---|---|
ε | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n
A | \\n0 | \\n0 | \\n0 | \\n0 | \\n0 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
G | \\n0 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n1 | \\n
T | \\n0 | \\n1 | \\n1 | \\n2 | \\n2 | \\n2 | \\n2 | \\n2 | \\n
A | \\n0 | \\n1 | \\n1 | \\n2 | \\n2 | \\n3 | \\n3 | \\n3 | \\n
B | \\n0 | \\n1 | \\n1 | \\n2 | \\n2 | \\n3 | \\n3 | \\n4 | \\n
Step 0
\\n\\nCool visualization, huh? But how does it work? I\'ll first present the short version of these steps, and then we\'ll dive into the details. Feel free to skip either one of the two, as long as you understand the concept.
\\nHere are the algorithm\'s steps, given two sequences, a
and b
, of lengths m
and n
:
(m + 1) x (n + 1)
matrix, dp
. Each column represents a character of a
, and each row represents a character of b
.0
), representing the number of matching characters between an empty string and the corresponding prefix.(i, j)
:\\na[i - 1]
equals b[j - 1]
, set dp[i][j] = dp[i - 1][j - 1] + 1
(increment the length of the LCS).dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
(take the maximum of the lengths of the LCS of the prefixes without the current characters).a[i - 1]
equals b[j - 1]
, add a[i - 1]
to the LCS and move diagonally upwards (to the cell (i - 1, j - 1)
).Like I said earlier, in order to solve the problem, we\'ll break it into smaller subproblems. The table in the visualization shows the lengths of the longest common subsequences of the prefixes of the two strings. The rows represent the characters of the first string, and the columns represent the characters of the second string. The cell at row i
and column j
contains the length of the longest common subsequence of the prefixes string1.slice(0, i)
and string2.slice(0, j)
. The empty string ε
represents the empty prefix (i.e., the length of the LCS of an empty string and any other string is 0
).
The table is filled in a bottom-up manner. We start with the empty string and build up the solution by considering the characters of the two strings one by one. If the characters match, we increment the length of the longest common subsequence by 1. If they don\'t match, we take the maximum of the lengths of the longest common subsequences of the prefixes without the current characters.
\\nHow do we get the LCS from this table? We start at the bottom right corner of the table and move diagonally upwards. If the characters at the current position match, we add the character to the LCS and move diagonally upwards. If they don\'t match, we move to the cell with the greater value. Doing so, we arrive at the LCS \'GTAB\'
.
Now that you better understand the problem and how to solve it, let\'s implement the algorithm in JavaScript. And, to spice things up a little, we\'ll make sure it can handle both strings and arrays.
\\nconst longestCommonSubsequence = (a, b) => {\\n // Find the lengths of the two sequences\\n const m = a.length, n = b.length;\\n\\n // Create a 2D array to store lengths\\n const dp = Array(m + 1).fill(0).map(() => Array(n + 1).fill(0));\\n\\n // Fill the 2D array\\n for (let i = 1; i <= m; i++) {\\n for (let j = 1; j <= n; j++) {\\n if (a[i - 1] === b[j - 1]) {\\n dp[i][j] = dp[i - 1][j - 1] + 1;\\n } else {\\n dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);\\n }\\n }\\n }\\n\\n // Reconstruct the LCS\\n let i = m, j = n, lcs = [];\\n\\n while (i > 0 && j > 0) {\\n if (a[i - 1] === b[j - 1]) {\\n lcs.unshift(a[i - 1]);\\n i--;\\n j--;\\n } else if (dp[i - 1][j] > dp[i][j - 1]) {\\n i--;\\n } else {\\n j--;\\n }\\n }\\n\\n if (typeof a === \'string\' && typeof b === \'string\')\\n return lcs.join(\'\');\\n\\n return lcs;\\n}\\n\\nlongestCommonSubsequence(\\n \'AGGTAB\',\\n \'GXTXAYB\'\\n); // \'GTAB\'\\n\\nlongestCommonSubsequence(\\n [\'A\',\'B\',\'C\',\'B\',\'A\'],\\n [\'C\', \'B\', \'A\', \'B\', \'A\', \'C\']\\n); // [\'C\', \'B\', \'A\']\\n
Our function takes two sequences, a
and b
, as arguments. It creates a 2D array dp
to store the lengths of the longest common subsequences of the prefixes of the two sequences. It then fills the array using the algorithm we discussed earlier. Finally, it reconstructs the LCS by moving diagonally upwards in the table. The function returns the LCS as a string if both a
and b
are strings, or as an array otherwise.
If this feels eerily similar to the Levenshtein distance algorithm, it\'s because it is! Both algorithms use dynamic programming to solve relatively similar problems.
\\nThat\'s all there is to it! You now know how to find the longest common subsequence of two strings or arrays using dynamic programming in JavaScript. If you have any questions or need further clarification, feel free to join the GitHub discussion, using the link below! I\'d love to hear your feedback on the visualization as well, as I\'m interested in creating more of these for future articles.
Last updated: February 3, 2025 View on GitHub ·\\nJoin the discussion
","description":"The longest common subsequence (LCS) is the longest subsequence common to all given sequences. It is not the same as the longest common substring, which must occupy consecutive positions within the original sequences. The LCS problem can be easily solved using dynamic…","guid":"https://www.30secondsofcode.org/js/s/longest-common-subsequence","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-02-02T16:00:00.020Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/carrots-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/carrots-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," String "],"attachments":null,"extra":null,"language":null},{"title":"Formatting day and hour ranges with JavaScript","url":"https://www.30secondsofcode.org/js/s/formatting-day-hour-ranges","content":"I\'ve long held the belief that algorithmic challenges are sort of a rarity in the world of web development, especially in frontend. But every now and then, you come across a problem that makes you think a little harder. This is exactly the case for a sort of formatting problem I recently encountered.
\\nInstead of boring you with all the details, I\'ll get straight to the specifics. Given an array of objects, containing day names and respective working hour ranges, I needed to format this data into an array of human-readable strings.
\\nLet\'s look at an example to make things clearer:
\\n// Sample input:\\nconst inputData = [\\n { day: \'Tuesday\', from: \'09:00\', to: \'17:00\' },\\n { day: \'Wednesday\', from: \'09:00\', to: \'17:00\' },\\n { day: \'Thursday\', from: \'09:00\', to: \'17:00\' },\\n { day: \'Friday\', from: \'09:00\', to: \'17:00\' },\\n { day: \'Saturday\', from: \'10:00\', to: \'14:00\' }\\n];\\n\\n// Expected output:\\nconst outputData = [\\n \'Monday: Closed\',\\n \'Tuesday - Friday: 09:00 - 17:00\',\\n \'Saturday: 10:00 - 14:00\',\\n \'Sunday: Closed\'\\n];\\n
Before even attempting to implement a solution, it\'s a good idea to break down the problem. In my head, this translates to the following subproblems:
\\nWhile my initial thoughts were to tackle these subproblems in order, I quickly realized that the third subproblem could be solved in parallel with the first one. This would make the solution more efficient and easier to implement.
\\nHaving broken down the problem, the solution can be approached in smaller steps, incrementally building up the final result. Let\'s start with the first subproblem.
\\nThis is the most algorithmic part of the problem and the one that may require the most thought to get right. I was vaguely aware of the fact that this problem is quite similar to the arrays of consecutive elements problem I\'ve previously solved.
\\nAfter a little bit of thought, I came up with a simple solution. I could use Array.prototype.reduce()
to iterate over array elements. But, as mentioned before, there may be missing elements. To account for this, instead of looping over the array, I decided to loop over the days of the week, a well-known set of elements.
Having the days of the week as a reference, I could then check if the current day matches the next day in the array. If it does, I could group them together. If not, I could add the current group to the result and start a new group.
\\nFinally, if the element is missing, I could add a value of \'Closed\'
to the group. This would allow me to easily identify missing days and add them to the final result, as well as group consecutive missing days together.
const weekdays = [\'Monday\', \'Tuesday\', \'Wednesday\', \'Thursday\', \'Friday\', \'Saturday\', \'Sunday\'];\\n\\nconst formatDayRanges = (data) =>\\n weekdays.reduce((acc, day) => {\\n const dayData = data.find((d) => d.day === day);\\n\\n const hours = dayData ? `${dayData.from} - ${dayData.to}` : \'Closed\';\\n\\n if (acc.length && acc[acc.length - 1].hours === hours) {\\n acc[acc.length - 1].days.push(day);\\n } else {\\n acc.push({ days: [day], hours });\\n }\\n\\n return acc;\\n }, []);\\n\\n// Given the sample input from the problem definition\\nconst result = formatDayRanges(inputData);\\n// [\\n// { days: [\'Monday\'], hours: \'Closed\' },\\n// {\\n// days: [\'Tuesday\', \'Wednesday\', \'Thursday\', \'Friday\'],\\n// hours: \'09:00 - 17:00\'\\n// },\\n// { days: [\'Saturday\'], hours: \'10:00 - 14:00\' },\\n// { days: [\'Sunday\'], hours: \'Closed\' }\\n// ]\\n
That\'s a great start! The next step is to format the grouped days into human-readable strings. Notice that we\'ve already formatted the hour ranges in the previous step to make comparison easier, which will come in handy here.
\\nIn the case of a single day, we\'ll simply output the day name and the working hours. For multiple days, we\'ll output the first and last day of the range, followed by the working hours. Additionally, I\'d like to make sure that special ranges of days are formatted correctly, such as when the range spans the entire week, all weekdays, or just the weekend.
\\nconst workingDays = [\'Monday\', \'Tuesday\', \'Wednesday\', \'Thursday\', \'Friday\'];\\nconst weekendDays = [\'Saturday\', \'Sunday\'];\\nconst allDays = [...workingDays, ...weekendDays];\\n\\nconst formatDayRanges = (data) =>\\n allDays.reduce((acc, day) => {\\n const dayData = data.find((d) => d.day === day);\\n\\n const hours = dayData ? `${dayData.from} - ${dayData.to}` : \'Closed\';\\n\\n if (acc.length && acc[acc.length - 1].hours === hours) {\\n acc[acc.length - 1].days.push(day);\\n } else {\\n acc.push({ days: [day], hours });\\n }\\n\\n return acc;\\n }, []).map(({ days, hours }) => {\\n if (days.length === 1) return `${days[0]}: ${hours}`;\\n if (days.length === 7) return `Everyday: ${hours}`;\\n if (workingDays.every((day) => days.includes(day)))\\n return \'Weekdays: \' + hours;\\n if (weekendDays.every((day) => days.includes(day)))\\n return \'Weekend: \' + hours;\\n return `${days[0]} - ${days[days.length - 1]}: ${hours}`;\\n });\\n\\n// Given the sample input from the problem definition\\nconst result = formatDayRanges(inputData);\\n// [\\n// \'Monday: Closed\',\\n// \'Tuesday - Friday: 09:00 - 17:00\',\\n// \'Saturday: 10:00 - 14:00\',\\n// \'Sunday: Closed\'\\n// ]\\n
Notice that we\'re using a second loop over the first grouped result in the form of Array.prototype.map()
. This can be avoided, but I found it to be more readable and easier to understand. Moreover, the performance impact is negligible.
Another point of interest is the use of Array.prototype.every()
to check if all days in a range are working days or weekend days. This is a simple and efficient way to check for special cases. However, were we to swap the places of days
and workingDays
(or weekendDays
), we\'d end up in a situation where we\'d have to check for the length of the days
array. This is because Array.prototype.every()
would return true
for an empty array or an array with fewer elements than the workingDays
or weekendDays
arrays.
This problem was a great exercise in breaking down a problem into smaller subproblems and incrementally building up the solution. It\'s a great example of how algorithmic thought can come in handy for everyday problems, even in frontend development. Hope you enjoyed my approach and learned something new!
Last updated: January 29, 2025 View on GitHub
","description":"I\'ve long held the belief that algorithmic challenges are sort of a rarity in the world of web development, especially in frontend. But every now and then, you come across a problem that makes you think a little harder. This is exactly the case for a sort of formatting problem I…","guid":"https://www.30secondsofcode.org/js/s/formatting-day-hour-ranges","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-01-28T16:00:00.183Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/tram-car-2-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/tram-car-2-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Date "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object advanced relationships","url":"https://www.30secondsofcode.org/js/s/complex-object-advanced-relationships","content":"This article is part of a series, picking up where Modeling complex JavaScript object autoloading and console left off. If you haven\'t read the previous installments yet, I strongly advise you to do so to get the full context. This series is more of a show & tell hoping to inspire you to build your own advanced JavaScript projects.
\\nWe\'ve already come a long way implementing an ActiveRecord-like JavaScript object system. While we\'ve already covered models, records and queries extensively, I want to revisit the relationships between objects. The current implementation is fairly basic and I believe we can do plenty more to make it more powerful.
\\nThis time, we\'ll simply add a new model under our models
directory. We\'ll also implement the relevant factory for it to match the rest of the implementation. And we\'ll make a few updates in the Model
class and our models to support the new features.
src/\\n├── config/\\n│ └── settings.js\\n├── core/\\n│ ├── model.js\\n│ ├── recordSet.js\\n│ ├── serializer.js\\n│ └── factory.js\\n├── models/\\n│ ├── author.js\\n│ ├── category.js\\n│ └── post.js\\n├── scripts/\\n│ ├── autoload.js\\n│ └── console.js\\n└── serializers/\\n ├── postSerializer.js\\n └── postPreviewSerializer.js\\nspec/\\n└── factories/\\n ├── authorFactory.js\\n ├── categoryFactory.js\\n └── postFactory.js\\n
You may need a code refresher before you begin and I\'ve got you covered. The entire implementation thus far is available in the code summary of the previous article.
\\nBefore we begin, I want to address a couple of things to make the rest of the implementation easier. Firstly, some cleaning up in the Model
class\'s prepare
method. Then, we\'ll add our new Category
model and factory, and finally, we\'ll add a relationships to our Post
model.
prepare
In the past, we\'ve heavily relied on the prepare
method of the Model
class to set up our models. As we\'re going to add even more logic to it, the arguments will start becoming a little hard to manage. Let\'s switch from a list of arguments to an object.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n // ...\\n }\\n\\n // ...\\n}\\n
This small change will break everything, so we need to update the models accordingly.
\\nimport Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this, {\\n fields: [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true, inspectable: false }],\\n ],\\n validations: [record => record.email.includes(\'@\')],\\n });\\n }\\n\\n // ...\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, {\\n fields: [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n [\'authorId\', \'number\'],\\n ],\\n });\\n }\\n\\n // ...\\n}\\n
Category
modelWe\'ll now create a Category
model and a relevant CategoryFactory
to match the rest of the implementation. We\'ll use this model as the main example for some of our relationships later in the article.
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Category extends Model {\\n static {\\n // Prepare storage for the Category model\\n super.prepare(this, {\\n fields: [[\'title\', { type: \'string\', allowEmpty: false }]],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n get posts() {\\n return Post.where({ categoryId: this.id });\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Category from \'#src/models/category.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Category #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n};\\n\\nexport default class CategoryFactory extends Factory {\\n static {\\n super.prepare(this, Category, base);\\n }\\n}\\n
But wait! Posts don\'t have a categoryId
field!, I hear you say. Let\'s add it, then!
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\nimport Category from \'#src/models/category.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, {\\n fields: [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n [\'authorId\', \'number\'],\\n [\'categoryId\', \'number\'],\\n ],\\n });\\n }\\n\\n // ...\\n\\n get category() {\\n return Category.find(this.categoryId);\\n }\\n}\\n
Now all of our posts are related to categories, much like they would in a real-world blogging setup. The relationship is one-to-many, where a post can belong to only one category, but a category can have many posts, same as it is with authors.
\\nWe\'ve previously defined relationships via the use of a field that is then used by an attribute method to fetch the related records. This works alright, but scaling the project will become a bit of a hassle. We\'ll now introduce a new way to define relationships, which will make the implementation more scalable.
\\nIn order to create relationship definitions, we\'ll lean on the prepare
method once more. We will be adding a relationships
array as part of our options object. The first element of the array will be the type of relationship, whereas the second one will be the related model.
Again, drawing inspiration from ActiveRecord, we\'ll start with the belongsTo
relationship. This relationship is used when a record belongs to another record. In this case, the record that belongs to the other record is the one defining the foreign key.
In our case, this is the Post
model, which belongs to an Author
via the authorId
field. The same applies to the relationship from Post
to Category
via the categoryId
field.
Let\'s start by adding the logic for the belongsTo
relationship in the Model
class.
// ...\\n\\nconst capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);\\nconst decapitalize = str => str.charAt(0).toLowerCase() + str.slice(1);\\nconst toSingular = str => str.replace(/s$/, \'\');\\n\\nexport default class Model {\\n static models = {};\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n const name = model.name;\\n\\n if (Model.models[name])\\n throw new Error(`Model ${name} has already been prepared`);\\n else Model.models[name] = model;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n model.validations = validations || [];\\n model.indexes = [];\\n model.fields = {};\\n\\n let relationshipFields = [];\\n\\n relationships.forEach(relationship => {\\n let [relationshipType, target] = relationship;\\n\\n if (relationshipType === \'belongsTo\') {\\n const propertyName = decapitalize(target);\\n const foreignKey = `${decapitalize(target)}Id`;\\n relationshipFields.push([foreignKey, \'number\']);\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(target)];\\n return targetModel.find(this[foreignKey]);\\n },\\n configurable: true,\\n });\\n } else {\\n throw new Error(`Invalid relationship type: ${relationshipType}`);\\n }\\n });\\n\\n [\'id\', ...fields, ...relationshipFields].forEach(field => {\\n // ...\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
As you can see, we\'ve added a new models
static variable to the Model
class. This serves as a storage for all initialized models. We then ensure that relationships are stored as field definitions in relationshipFields
, which will then be used to create the individual fields automatically.
Finally, we arrive at the getter definition. If you remember from previous articles, we use Object.defineProperty
towards the end of the prepare
method to redefine getters, so that they can utilize the getterCache
for performance reasons. In order to retain this performance enhancement, we need to create our new getter as configurable
, so that it can be then redefined later down the line.
Additionally, you may notice the targetModel
declaration is inside the get
method. This is a crucial point to take note of, as it ensures that the target model is fetched only when the getter is accessed. This is important, as models can be initialized in any order and, if we move it outside of the getter, it may not be available at definition time.
Having done all this, we can now update our Post
model to include the belongsTo
relationships. We\'ll have to delete the authorId
, categoryId
, author
and category
fields and methods, as they are no longer needed. And, this way, we can drop the imports for Author
and Category
as well.
import Model from \'#src/core/model.js\';\\n// Delete the imports for Author and Category\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, {\\n fields: [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n // Delete the authorId and categoryId fields\\n ],\\n relationships: [\\n [\'belongsTo\', \'author\'],\\n [\'belongsTo\', \'category\'],\\n ],\\n });\\n }\\n\\n // ...\\n\\n // Delete the author and category methods\\n}\\n
The previous code looks a lot tidier now and we can easily add more relationships in the future without having to worry about the implementation details. While we do not have any hasOne
relationships in our current setup, we\'ll go ahead an implement the logic regardless.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n // ...\\n\\n relationships.forEach(relationship => {\\n let [relationshipType, target] = relationship;\\n\\n if (relationshipType === \'belongsTo\') {\\n const propertyName = decapitalize(target);\\n const foreignKey = `${decapitalize(target)}Id`;\\n relationshipFields.push([foreignKey, \'number\']);\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(target)];\\n return targetModel.find(this[foreignKey]);\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasOne\') {\\n const propertyName = decapitalize(target);\\n const foreignKey = `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(target)];\\n return targetModel.where({ [foreignKey]: this.id }).first;\\n },\\n configurable: true,\\n });\\n } else {\\n throw new Error(`Invalid relationship type: ${relationshipType}`);\\n }\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
As you can see, very little has changed from the previous setup. Instead of using the find
method on the other model, we use where
with a query that utilizes the foreignKey
to find the related record. This is a very simple implementation, but it should be enough for most use cases.
As the hasOne
relationships expects the foreignKey
to be on the other model, we don\'t need to add any additional fields to the current model, unlike belongsTo
relationships.
But why not findBy
? I hear you asking. Well, there is no guarantee that the target field is unique, thus it may not have an index. If you have implemented indexes that are not tied to uniqueness constraints or have tweaked findBy
to handle non-indexed fields, then you can use it instead.
Similar to the hasOne
relationship, we can implement a hasMany
relationship. To be honest, this is almost the exact same piece of code, except we\'ll remove the first
method call at the end of the getter.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n // ...\\n\\n relationships.forEach(relationship => {\\n let [relationshipType, target] = relationship;\\n\\n if (relationshipType === \'belongsTo\') {\\n const propertyName = decapitalize(target);\\n const foreignKey = `${decapitalize(target)}Id`;\\n relationshipFields.push([foreignKey, \'number\']);\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(target)];\\n return targetModel.find(this[foreignKey]);\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasOne\') {\\n const propertyName = decapitalize(target);\\n const foreignKey = `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(target)];\\n return targetModel.where({ [foreignKey]: this.id }).first;\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasMany\') {\\n const propertyName = decapitalize(target);\\n const foreignKey = `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel =\\n Model.models[capitalize(toSingular(target))];\\n return targetModel.where({ [foreignKey]: this.id });\\n },\\n configurable: true,\\n });\\n } else {\\n throw new Error(`Invalid relationship type: ${relationshipType}`);\\n }\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
Ok, apart from that little detail, you may have noticed that we also ensure the model name is singular. This is on purpose, so we can declare the relationship in a more human-readable way. For example, in the Author
model, we can declare a hasMany
relationship to Post
as posts
instead of post
.
I know that my toSingular
method is very simplistic and will only work for some English words. Implementing a more robust solution is outside the scope of this article, however, we\'ll take a look at aliases and foreign keys in the next section, which should solve the problem even better.
Let\'s go ahead and add a hasMany
relationship to the Author
model. We\'ll also delete the posts
method, as it is no longer needed, as well as the import for the Post
model. While we\'re at it, let\'s do the exact same for the Category
model.
import Model from \'#src/core/model.js\';\\n// Delete the import for Post\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this, {\\n fields: [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true, inspectable: false }],\\n ],\\n validations: [record => record.email.includes(\'@\')],\\n relationships: [[\'hasMany\', \'posts\']],\\n });\\n }\\n\\n // ...\\n\\n // Delete the posts method\\n}\\n
import Model from \'#src/core/model.js\';\\n// Delete the import for Post\\n\\nexport default class Category extends Model {\\n static {\\n // Prepare storage for the Category model\\n super.prepare(this, {\\n fields: [[\'title\', { type: \'string\', allowEmpty: false }]],\\n relationships: [[\'hasMany\', \'posts\']],\\n });\\n }\\n\\n // ...\\n\\n // Delete the posts method\\n}\\n
Same as last time, I haven\'t implemented many-to-many relationships to minimize complexity. They\'re left as a task for the reader, if you want to get your hands dirty and you feel like you truly need them for your use case.
\\nSo far so good, but customization is definitely going to be an issue in the long run. I want to create a taxonomy of categories, too. This will require categories that have a parent category, which is a category itself. This is a self-referential relationship and we\'ll need to add a little bit of logic to handle it. And we need to address naming conventions, too.
\\nUp until this point, foreign keys have been dictated by the target model\'s name. This is a good default, but it\'s not always going to work. We may want to go down the field definition route and allow our second array element to be an object. If it is, we can let it define a target
(the model name) and a foreignKey
(the field name). This will allow us to effectively decouple the two.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n // ...\\n\\n relationships.forEach(relationship => {\\n let [relationshipType, options] = relationship;\\n if (typeof options === \'string\') options = { target: options };\\n\\n if (relationshipType === \'belongsTo\') {\\n const propertyName = decapitalize(options.target);\\n const foreignKey =\\n options.foreignKey || `${decapitalize(options.target)}Id`;\\n relationshipFields.push([foreignKey, \'number\']);\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(options.target)];\\n return targetModel.find(this[foreignKey]);\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasOne\') {\\n const propertyName = decapitalize(options.target);\\n const foreignKey = options.foreignKey || `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(options.target)];\\n return targetModel.where({ [foreignKey]: this.id }).first;\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasMany\') {\\n const propertyName = decapitalize(options.target);\\n const foreignKey = options.foreignKey || `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel =\\n Model.models[capitalize(toSingular(options.target))];\\n return targetModel.where({ [foreignKey]: this.id });\\n },\\n configurable: true,\\n });\\n } else {\\n throw new Error(`Invalid relationship type: ${relationshipType}`);\\n }\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
Great! Let\'s apply this to create a parent category relationship in the Category
model.
import Model from \'#src/core/model.js\';\\n\\nexport default class Category extends Model {\\n static {\\n // Prepare storage for the Category model\\n super.prepare(this, {\\n fields: [[\'title\', { type: \'string\', allowEmpty: false }]],\\n relationships: [\\n [\\n \'belongsTo\',\\n { target: \'category\', foreignKey: \'parentId\' },\\n ],\\n ],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static root(records) {\\n return records.where({ parentId: null }).first;\\n }\\n}\\n
const rootCategory = new Category({ title: \'Root\', parentId: null });\\nconst subCategory = new Category({ title: \'Sub\', parentId: rootCategory.id });\\n\\nsubCategory.category;\\n// [Category #0x00000000]: { id: 0, title: \'Root\', parentId: null }\\n
Not quite what we were looking for. While the foreign key has the correct name and the relationship is applied, the relationship attribute is still called category
. We can fix this!
If you were guessing we\'re going to implement aliases via the as
property, you\'re absolutely right! This will allow us to define a custom name for the relationship attribute. Let\'s go ahead and modify the prepare
method one last time.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n // ...\\n\\n relationships.forEach(relationship => {\\n let [relationshipType, options] = relationship;\\n if (typeof options === \'string\')\\n options = { target: options, as: decapitalize(options) };\\n\\n if (relationshipType === \'belongsTo\') {\\n const propertyName = options.as || decapitalize(options.target);\\n const foreignKey =\\n options.foreignKey || `${decapitalize(options.target)}Id`;\\n relationshipFields.push([foreignKey, \'number\']);\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(options.target)];\\n return targetModel.find(this[foreignKey]);\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasOne\') {\\n const propertyName = options.as || decapitalize(options.target);\\n const foreignKey = options.foreignKey || `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(options.target)];\\n return targetModel.where({ [foreignKey]: this.id }).first;\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasMany\') {\\n const propertyName = options.as || decapitalize(options.target);\\n const foreignKey = options.foreignKey || `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel =\\n Model.models[capitalize(toSingular(options.target))];\\n return targetModel.where({ [foreignKey]: this.id });\\n },\\n configurable: true,\\n });\\n } else {\\n throw new Error(`Invalid relationship type: ${relationshipType}`);\\n }\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
Now, we can make update the Category
model to reflect the new as
property.
import Model from \'#src/core/model.js\';\\n\\nexport default class Category extends Model {\\n static {\\n // Prepare storage for the Category model\\n super.prepare(this, {\\n fields: [[\'title\', { type: \'string\', allowEmpty: false }]],\\n relationships: [\\n [\\n \'belongsTo\',\\n { target: \'category\', foreignKey: \'parentId\', as: \'parent\' },\\n ],\\n ],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static root(records) {\\n return records.where({ parentId: null }).first;\\n }\\n}\\n
const rootCategory = new Category({ title: \'Root\', parentId: null });\\nconst subCategory = new Category({ title: \'Sub\', parentId: rootCategory.id });\\n\\nsubCategory.parent;\\n// [Category #0x00000000]: { id: 0, title: \'Root\', parentId: null }\\n
What we did with the parent
relationship is what one would call a self-referential relationship. This is a relationship where a record can be related to another record of the same model. This concept can come in handy in various scenarios.
Another way to use this, for example, is to create the inverse relationship, where a category can have many subcategories. There\'s nothing stopping us from defining it in the Category
model, same as before.
import Model from \'#src/core/model.js\';\\n\\nexport default class Category extends Model {\\n static {\\n // Prepare storage for the Category model\\n super.prepare(this, {\\n fields: [[\'title\', { type: \'string\', allowEmpty: false }]],\\n relationships: [\\n [\\n \'belongsTo\',\\n { target: \'category\', foreignKey: \'parentId\', as: \'parent\' },\\n ],\\n [\\n \'hasMany\',\\n { target: \'category\', foreignKey: \'parentId\', as: \'children\' },\\n ],\\n ],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static root(records) {\\n return records.where({ parentId: null }).first;\\n }\\n}\\n
const rootCategory = new Category({ title: \'Root\', parentId: null });\\nconst subCategory = new Category({ title: \'Sub\', parentId: rootCategory.id });\\n\\nrootCategory.children;\\n// [Category #0x00000001]: { id: 1, title: \'Sub\', parentId: 0 }\\n
And there you have it! We\'ve successfully implemented self-referential relationships in our model, both in the form of a parent category and a category with subcategories.
\\nAfter a long and winding road, we\'ve finally implemented a very powerful relationship system for our models. We can now define relationships quickly and easily, customize them and even implement self-referential relationships. This is a very powerful setup and can help us scale our project to new heights.
\\nI hope you found this deep dive interesting and that you\'ve discovered a new way to think about complexity. This is going to be the last article of the series, at least for now. If you\'ve made it this far, thank you for sticking with me. Join the GitHub discussion via the link below, and let me know if you liked the series or if you have any questions.
\\nBefore I leave you, here\'s the entire implementation of this series. You can use this as a reference or as a starting point for your own projects. Enjoy!
\\nYou can also browse through the Code Reference on GitHub.
\\nconst settings = {\\n loader: {\\n modules: [\\n \'#src/core\',\\n \'#src/models\',\\n \'#src/serializers\',\\n \'#spec/factories\',\\n ],\\n },\\n};\\n
import RecordSet from \'#src/core/recordSet.js\';\\n\\nimport util from \'util\';\\n\\nutil.inspect.styles.record = \'blue\';\\nconst customInspectSymbol = Symbol.for(\'nodejs.util.inspect.custom\');\\n\\nconst capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);\\nconst decapitalize = str => str.charAt(0).toLowerCase() + str.slice(1);\\nconst toSingular = str => str.replace(/s$/, \'\');\\n\\nexport default class Model {\\n static models = {};\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model, { fields, validations = [], relationships = [] }) {\\n const name = model.name;\\n\\n if (Model.models[name])\\n throw new Error(`Model ${name} has already been prepared`);\\n else Model.models[name] = model;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n model.validations = validations || [];\\n model.indexes = [];\\n model.fields = {};\\n\\n let relationshipFields = [];\\n\\n relationships.forEach(relationship => {\\n let [relationshipType, options] = relationship;\\n if (typeof options === \'string\')\\n options = { target: options, as: decapitalize(options) };\\n\\n if (relationshipType === \'belongsTo\') {\\n const propertyName = options.as || decapitalize(options.target);\\n const foreignKey =\\n options.foreignKey || `${decapitalize(options.target)}Id`;\\n relationshipFields.push([foreignKey, \'number\']);\\n\\n // Note: targetModel MUST be evaluated inside the getter, as it could\\n // be defined after the current model\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(options.target)];\\n return targetModel.find(this[foreignKey]);\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasOne\') {\\n const propertyName = options.as || decapitalize(options.target);\\n const foreignKey = options.foreignKey || `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel = Model.models[capitalize(options.target)];\\n return targetModel.where({ [foreignKey]: this.id }).first;\\n },\\n configurable: true,\\n });\\n } else if (relationshipType === \'hasMany\') {\\n const propertyName = options.as || decapitalize(options.target);\\n const foreignKey = options.foreignKey || `${decapitalize(name)}Id`;\\n\\n Object.defineProperty(model.prototype, propertyName, {\\n get() {\\n const targetModel =\\n Model.models[capitalize(toSingular(options.target))];\\n return targetModel.where({ [foreignKey]: this.id });\\n },\\n configurable: true,\\n });\\n } else {\\n throw new Error(`Invalid relationship type: ${relationshipType}`);\\n }\\n });\\n\\n [\'id\', ...fields, ...relationshipFields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n unique: false,\\n inspectable: true,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n ...fieldOptions,\\n type: \'number\',\\n allowEmpty: false,\\n unique: true,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const {\\n type: dataType,\\n allowEmpty,\\n defaultValue,\\n unique,\\n inspectable,\\n } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldTypeChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n let fieldChecker = fieldTypeChecker;\\n if (unique) {\\n model.indexes.push(fieldName);\\n\\n const uniqueChecker = value =>\\n !Model.indexedInstances[name][fieldName].has(value);\\n\\n fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value);\\n }\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue, inspectable };\\n });\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = model.indexes.reduce((acc, index) => {\\n acc[index] = new Map();\\n return acc;\\n }, {});\\n }\\n\\n Object.entries(Object.getOwnPropertyDescriptors(model.prototype)).forEach(\\n ([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n }\\n );\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker, defaultValue }]) => {\\n this[fieldName] = data[fieldName] ?? defaultValue;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n this.constructor.validations?.forEach(validation => {\\n if (!validation(this, Model.instances[modelName])) {\\n throw new Error(\\n `Invalid data for ${modelName} model: ${JSON.stringify(this)}`\\n );\\n }\\n });\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n this.constructor.indexes.forEach(index => {\\n Model.indexedInstances[modelName][index].set(this[index], this);\\n });\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].id.get(id);\\n }\\n\\n static findBy(fieldAndValue) {\\n const entries = Object.entries(fieldAndValue);\\n if (entries.length !== 1)\\n throw new Error(\'findBy method must receive a single field/value pair\');\\n\\n const [fieldName, value] = entries[0];\\n return this.indexedInstances[this.name][fieldName].get(value);\\n }\\n\\n [customInspectSymbol](depth, options) {\\n const modelName = this.constructor.name;\\n const id = `0x${this.id.toString(16).slice(0, 8).padStart(8, \'0\')}`;\\n const inspectable = Object.entries(this.constructor.fields).reduce(\\n (obj, [fieldName, { inspectable }]) => {\\n if (inspectable) obj[fieldName] = this[fieldName];\\n return obj;\\n },\\n {}\\n );\\n\\n if (depth <= 1) return options.stylize(`{ ${modelName} #${id} }`, \'record\');\\n\\n return `${options.stylize(`[${modelName} #${id}]: {`, \'record\')}${util\\n .inspect({ ...inspectable }, { ...options, depth: depth - 1 })\\n .slice(1, -1)}${options.stylize(\'}\', \'record\')}`;\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
export default class Serializer {\\n static prepare(serializer, serializableAttributes) {\\n serializer.serializableAttributes = [];\\n\\n serializableAttributes.forEach((attribute) => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias) return this.subject[attributeName];\\n if (typeof alias === \\"string\\") return this.subject[alias];\\n if (typeof alias === \\"function\\")\\n return alias(this.subject, this.options);\\n return undefined;\\n },\\n });\\n });\\n }\\n\\n constructor(subject, options = {}) {\\n this.subject = subject;\\n this.options = options;\\n }\\n\\n static serialize(subject, options) {\\n return new this(subject, options).serialize();\\n }\\n\\n static serializeArray(subjects, options) {\\n return subjects.map((subject) => this.serialize(subject, options));\\n }\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n\\nconst sequenceSymbol = Symbol(\'sequence\');\\n\\nconst isSequence = value =>\\n value && typeof value[sequenceSymbol] === \'function\';\\n\\nexport default class Factory {\\n static factoryMap = new Map();\\n static modelMap = new Map();\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const modelName = model.name;\\n\\n const factoryBase = {};\\n\\n Object.keys(base).forEach(key => {\\n const value = base[key];\\n const getter = isSequence(value) ? value[sequenceSymbol] : () => value;\\n Object.defineProperty(factoryBase, key, {\\n get: getter,\\n enumerable: true,\\n });\\n });\\n\\n if (!Factory.modelMap.has(modelName))\\n Factory.modelMap.set(modelName, factory);\\n\\n Object.defineProperty(factory, \'build\', {\\n value: function (...desiredTraits) {\\n const data = { ...factoryBase };\\n\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n },\\n });\\n }\\n\\n static sequence = (fn = n => n) => {\\n let i = 0;\\n const sequence = () => fn(i++);\\n return { [sequenceSymbol]: sequence };\\n };\\n\\n static build(model, ...desiredTraits) {\\n return Factory.modelMap.get(model).build(...desiredTraits);\\n }\\n\\n static buildArray(model, count, ...desiredTraits) {\\n return Array.from({ length: count }, () =>\\n Factory.build(model, ...desiredTraits)\\n );\\n }\\n\\n static clear(model) {\\n Model.instances[model] = [];\\n Model.indexedInstances[model] = new Map();\\n Model.getterCache[model] = {};\\n }\\n\\n static clearAll() {\\n Model.instances = {};\\n Model.indexedInstances = {};\\n Model.getterCache = {};\\n }\\n}\\n
import { readdir } from \'node:fs/promises\';\\n\\nimport settings from \'#src/config/settings.js\';\\n\\nconst capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);\\n\\nconst autoload = async () => {\\n const moduleMap = new Map();\\n\\n for (const path of settings.loader.modules) {\\n // Read each directory (this requires a path relative to the project root)\\n const moduleFiles = await readdir(path.replace(/^#/, \'./\'));\\n\\n for (const moduleFile of moduleFiles) {\\n // Convert the file name to a module name (e.g., post.js -> Post)\\n const moduleName = capitalize(moduleFile.split(\'.\')[0]);\\n\\n if (!moduleMap.has(moduleName)) {\\n // Dynamically import the module and add it to the map\\n const module = await import(`${path}/${moduleFile}`);\\n moduleMap.set(moduleName, module.default);\\n } else throw new Error(`Duplicate class name: ${moduleName}`);\\n }\\n }\\n\\n // Convert the map to an object and return it, so that it can be exported\\n return Object.fromEntries(moduleMap.entries());\\n};\\n\\nconst modules = await autoload();\\n\\nexport default { ...modules, settings };\\n
import repl from \'node:repl\';\\nimport modules from \'#src/scripts/autoload.js\';\\n\\n// Start the REPL server\\nconst replServer = repl.start();\\n// Set up a history file for the REPL\\nreplServer.setupHistory(\'repl.log\', () => {});\\n\\n// Add the autoloaded modules to the REPL context\\nObject.entries(modules).forEach(([moduleName, module]) => {\\n replServer.context[moduleName] = module;\\n});\\n
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, {\\n fields: [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n ],\\n relationships: [\\n [\'belongsTo\', \'author\'],\\n [\'belongsTo\', \'category\'],\\n ],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n// Delete: import Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this, {\\n fields: [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true, inspectable: false }],\\n ],\\n validations: [record => record.email.includes(\'@\')],\\n relationships: [[\'hasMany\', \'posts\']],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n\\nexport default class Category extends Model {\\n static {\\n // Prepare storage for the Category model\\n super.prepare(this, {\\n fields: [[\'title\', { type: \'string\', allowEmpty: false }]],\\n relationships: [\\n [\'hasMany\', \'posts\'],\\n [\\n \'belongsTo\',\\n { target: \'category\', foreignKey: \'parentId\', as: \'parent\' },\\n ],\\n [\\n \'hasMany\',\\n { target: \'category\', foreignKey: \'parentId\', as: \'children\' },\\n ],\\n ],\\n });\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static root(records) {\\n return records.where({ parentId: null }).first;\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n super.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }],\\n [\'author\', (post, options) => {\\n const author = post.author;\\n const result = { name: author.fullName };\\n if (options.showEmail) result.email = author.email;\\n return result;\\n }]\\n ]);\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostPreviewSerializer extends Serializer {\\n static {\\n super.prepare(this, [\\n \'title\',\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n month: \'short\',\\n day: \'numeric\',\\n year: \'numeric\'\\n });\\n }],\\n [\'author\', post => post.author.fullName],\\n [\'url\', post => `/posts/${post.id}`]\\n ]);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nconst idSequence = Factory.sequence();\\n\\nconst base = {\\n id: idSequence,\\n name: \'Author\',\\n surname: \'Authorson\',\\n email: \'author@authornet.io\',\\n};\\n\\nexport default class AuthorFactory extends Factory {\\n static {\\n super.prepare(this, Author, base);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Post #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n content: \'Post content\',\\n};\\n\\nconst traits = {\\n published: {\\n publishedAt: new Date(),\\n },\\n unpublished: {\\n publishedAt: null,\\n },\\n};\\n\\nexport default class PostFactory extends Factory {\\n static {\\n super.prepare(this, Post, base, traits);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Category from \'#src/models/category.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Category #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n parentId: null,\\n};\\n\\nexport default class CategoryFactory extends Factory {\\n static {\\n super.prepare(this, Category, base);\\n }\\n}\\n
Last updated: January 23, 2025 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This article is part of a series, picking up where Modeling complex JavaScript object autoloading and console left off. If you haven\'t read the previous installments yet, I strongly advise you to do so to get the full context. This series is more of a show & tell…","guid":"https://www.30secondsofcode.org/js/s/complex-object-advanced-relationships","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-01-22T16:00:00.647Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/digital-nomad-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/digital-nomad-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object autoloading and console","url":"https://www.30secondsofcode.org/js/s/complex-object-autoloading-console","content":"This article is part of a series, following Modeling complex JavaScript object field validation. It\'s highly recommended to read the previous articles to get the full context. The whole series is more of a show & tell hoping to inspire you to start that advanced JavaScript project you\'ve been thinking about.
\\nSo far in this series, we\'ve developed models, queries, scopes, serialization, and factories for our ActiveRecord-inspired project. As the project grows larger, I want to address autoloading and a console environment to interact with our objects. In this installment, we\'ll create an inspect utility to help us work with objects in the console.
\\nThis time, we\'re expanding the directory structure just a little. We\'ll add a new config
directory for settings and a scripts
directory for our autoload and console scripts.
src/\\n├── config/\\n│ └── settings.js\\n├── core/\\n│ ├── model.js\\n│ ├── recordSet.js\\n│ ├── serializer.js\\n│ └── factory.js\\n├── models/\\n│ ├── author.js\\n│ └── post.js\\n├── scripts/\\n│ ├── autoload.js\\n│ └── console.js\\n└── serializers/\\n ├── postSerializer.js\\n └── postPreviewSerializer.js\\nspec/\\n└── factories/\\n ├── authorFactory.js\\n └── postFactory.js\\n
If you need a refresher of the entire implementation thus far, it\'s available in the code summary section at the end of the previous article.
\\nWhile developing the project up until this point, I\'ve used Node.js and its REPL to interact with the objects. However, I\'ve found it cumbersome to require all the modules manually. This concern will only get more significant as the project grows.
\\nBefore we dive into the autoloader, let\'s create a settings.js
file in the config
directory. This file will hold the settings for our project. For the time being, we\'ll simply define a setting for which modules to autoload.
const settings = {\\n loader: {\\n modules: [\\n \'#src/core\',\\n \'#src/models\',\\n \'#src/serializers\',\\n \'#spec/factories\',\\n ],\\n },\\n};\\n\\nexport default settings;\\n
While the settings file might be a bit of an overkill for what appears to be a single setting, it\'s a good practice to have a central place for all the settings. This way, you can easily add more settings as your project grows.
\\nOne of the features that I like a lot about ActiveRecord is the cleanliness of its modules. I never have to declare which modules I need, as they\'re all automatically loaded. Granted, this feels like magic and a bit of a black box, but it\'s a feature I want to replicate, at least to some extent.
\\nAfter fiddling around with a few ideas, such as generating an index file for each directory, I settled on a fairly simple solution.
\\nI\'ll first use the fs
module to read the directories and find all the files in them. Then, I\'ll create a Map
and use import()
to dynamically load each module. Provided that naming conventions are followed, I\'ll be able to deduce the module name from the file path, much like Rails does. Then, I\'ll convert the Map
to an object and export it.
import { readdir } from \'node:fs/promises\';\\n\\nimport settings from \'#src/config/settings.js\';\\n\\nconst capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);\\n\\nconst autoload = async () => {\\n const moduleMap = new Map();\\n\\n for (const path of settings.loader.modules) {\\n // Read each directory (this requires a path relative to the project root)\\n const moduleFiles = await readdir(path.replace(/^#/, \'./\'));\\n\\n for (const moduleFile of moduleFiles) {\\n // Convert the file name to a module name (e.g., post.js -> Post)\\n const moduleName = capitalize(moduleFile.split(\'.\')[0]);\\n\\n if (!moduleMap.has(moduleName)) {\\n // Dynamically import the module and add it to the map\\n const module = await import(`${path}/${moduleFile}`);\\n moduleMap.set(moduleName, module.default);\\n } else throw new Error(`Duplicate class name: ${moduleName}`);\\n }\\n }\\n\\n // Convert the map to an object and return it, so that it can be exported\\n return Object.fromEntries(moduleMap.entries());\\n};\\n\\nconst modules = await autoload();\\n\\nexport default { ...modules, settings };\\n
As you\'ll notice, this implementation is pretty barebones, as it only handles single-level directories and single-export modules. However, it\'s a good starting point for a small project. You can take a stab at improving it by adding more features, such as nested directories or multiple exports, if you need them.
\\nWith the autoloader in place, we can now focus on creating a console environment to interact with our objects. We\'ll start by setting up the console.js
script, then we\'ll create a custom object inspect utility.
The console.js
script will be the entry point for our console environment. It will import the autoloaded modules and set up the REPL. For that last part, we\'ll iterate over the modules and make them available in the REPL context.
import repl from \'node:repl\';\\nimport modules from \'#src/scripts/autoload.js\';\\n\\n// Start the REPL server\\nconst replServer = repl.start();\\n// Set up a history file for the REPL\\nreplServer.setupHistory(\'repl.log\', () => {});\\n\\n// Add the autoloaded modules to the REPL context\\nObject.entries(modules).forEach(([moduleName, module]) => {\\n replServer.context[moduleName] = module;\\n});\\n
We can now add a script to the package.json
file to start the console environment.
{\\n \\"scripts\\": {\\n \\"console\\": \\"node src/scripts/console.js\\"\\n }\\n}\\n
Let\'s run it with npm run console
and create an Author
record to see how things work.
Factory.build(\'Author\');\\n// Author {\\n// id: 0,\\n// name: \'Author\',\\n// surname: \'Authorson\',\\n// email: \'author@authornet.io\'\\n// }\\n
The previous output is alright, but when our models inevitably become huge, we might wish we had customized the object inspection. Luckily, Node.js provides a way to do just that, using the util.inspect.custom
symbol.
In order to make our records stand out, we\'ll start by adding a custom value to util.inspect.styles
, called record
and set it to blue
(as far as I can tell, nothing uses this style by default).
import RecordSet from \'#src/core/recordSet.js\';\\n\\nimport util from \'util\';\\n\\nutil.inspect.styles.record = \'blue\';\\nconst customInspectSymbol = Symbol.for(\'nodejs.util.inspect.custom\');\\n\\n// ...\\n
Adding custom styles like this is undocumented behavior, thus it may be subject to change in future versions of Node.js. However, the underlying structure seems to be a simple object, so I don\'t see any real risk to making this benign change at the time of writing. Please exercise caution, regardless.
\\nThen, we\'ll add the new method to our Model
class. For starters, let\'s just make sure that we wrap the model name with square brackets and add the record id. The rest is going to be the same, except we\'ll color the square bracket part, its contents and the opening and closing curly braces blue.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n [customInspectSymbol](depth, options) {\\n const modelName = this.constructor.name;\\n const { id } = this;\\n\\n return `${options.stylize(`[${modelName} #${id}]: {`, \'record\')}${util\\n .inspect({ ...this }, { ...options, depth: depth - 1 })\\n .slice(1, -1)}${options.stylize(\'}\', \'record\')}`;\\n }\\n}\\n
Now, when we run the console environment and create an Author
record, we\'ll see the following output.
Factory.build(\'Author\');\\n// [Author #0]: {\\n// id: 0,\\n// name: \'Author\',\\n// surname: \'Authorson\',\\n// email: \'author@authornet.io\'\\n// }\\n
The output is already looking better, but we can still improve it. For instance, if we have an array of Post
records, we might want to see the records compactly displayed.
You might have noticed the depth
parameter from the previous method. We can use it to control the depth of the inspection. We\'ll update our inspect utility to compact nested records, depending on the depth
parameter.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n [customInspectSymbol](depth, options) {\\n const modelName = this.constructor.name;\\n const { id } = this;\\n\\n if (depth <= 1) return options.stylize(`{ ${modelName} #${id} }`, \'record\');\\n\\n return `${options.stylize(`[${modelName} #${id}]: {`, \'record\')}${util\\n .inspect({ ...this }, { ...options, depth: depth - 1 })\\n .slice(1, -1)}${options.stylize(\'}\', \'record\')}`;\\n }\\n}\\n
Factory.buildArray(\'Post\', 3);\\n// [ [Post #0], [Post #1], [Post #2] ]\\n
The depth
parameter is set to 2
by default. For the requirements of this example, 1
does the trick, but feel free to change the value in the condition to 0
, if you find it\'s more suitable for your needs.
One more thing we can do is convert the id to a hexadecimal string. This is more of a cosmetic change, but it can make the output look a little bit cleaner.
\\nAll this takes is the use of Number.prototype.toString()
with a 16
radix and String.prototype.padStart()
to ensure that the string is always 8
characters long.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n [customInspectSymbol](depth, options) {\\n const modelName = this.constructor.name;\\n const id = `0x${this.id.toString(16).slice(0, 8).padStart(8, \'0\')}`;\\n\\n if (depth <= 1) return options.stylize(`{ ${modelName} #${id} }`, \'record\');\\n\\n return `${options.stylize(`[${modelName} #${id}]: {`, \'record\')}${util\\n .inspect({ ...this }, { ...options, depth: depth - 1 })\\n .slice(1, -1)}${options.stylize(\'}\', \'record\')}`;\\n }\\n}\\n
Factory.build(\'Author\');\\n// [Author #0x00000000]: {\\n// id: 0,\\n// name: \'Author\',\\n// surname: \'Authorson\',\\n// email: \'author@authornet.io\'\\n// }\\n
Why 8 characters long? Because, realistically, we\'re not going to have more than 2^32
records in memory at any given time. If you somehow end up with more than that, you can always increase the length. I\'d advise you to take a long, hard look at your design decisions, though, as this implementation isn\'t meant to handle such large datasets.
So far, all of the fields in our models have been inspectable, which is the default behavior. However, we might want to hide some fields from the inspection. Say, for instance, that we have a password
field or some personal data that is sensitive and shouldn\'t be displayed in the console.
Luckily, our field definition implementation from last time can be easily tweaked to support an inspectable
option. Let\'s update the prepare
method in the Model
class, as well as our inspect utility to respect this new option.
// ...\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, fields, validations) {\\n // ...\\n\\n [\'id\', ...fields].forEach(field => {\\n // ...\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n unique: false,\\n inspectable: true,\\n };\\n\\n // ...\\n\\n const {\\n type: dataType,\\n allowEmpty,\\n defaultValue,\\n unique,\\n inspectable,\\n } = fieldOptions;\\n\\n // ...\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue, inspectable };\\n });\\n\\n // ...\\n }\\n\\n // ...\\n\\n [customInspectSymbol](depth, options) {\\n const modelName = this.constructor.name;\\n const id = `0x${this.id.toString(16).slice(0, 8).padStart(8, \'0\')}`;\\n const inspectable = Object.entries(this.constructor.fields).reduce(\\n (obj, [fieldName, { inspectable }]) => {\\n if (inspectable) obj[fieldName] = this[fieldName];\\n return obj;\\n },\\n {}\\n );\\n\\n if (depth <= 1) return options.stylize(`{ ${modelName} #${id} }`, \'record\');\\n\\n return `${options.stylize(`[${modelName} #${id}]: {`, \'record\')}${util\\n .inspect({ ...inspectable }, { ...options, depth: depth - 1 })\\n .slice(1, -1)}${options.stylize(\'}\', \'record\')}`;\\n }\\n}\\n
This simple change ensures that only fields marked as inspectable
(defaults to true
for all fields) are displayed in the console. We can then go ahead and mark the Author
model\'s email
field as not inspectable.
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(\\n this,\\n [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true, inspectable: false }],\\n ],\\n [record => record.email.includes(\'@\')]\\n );\\n }\\n\\n // ...\\n}\\n
Factory.build(\'Author\');\\n// [Author #0x00000000]: { id: 0, name: \'Author\', surname: \'Authorson\' }\\n
Our ActiveRecord-inspired project is starting to address the pains of scaling. We can finally easily load all the modules and interact with them in the console. We\'ve also created a custom object inspect utility to help us debug our complex objects and hide sensitive data from the console.
\\nI may have a couple of things to address before we wrap the series, but this article is already long enough. Don\'t forget to join the discussion on GitHub, using the link down below, or just drop a reaction to let me know if you\'re enjoying the series!
\\nThe customary code summary of the entire implementation up until this point can be found below. Make sure to bookmark it, if you need a quick reference in the future.
\\nYou can also browse through the Code Reference on GitHub.
\\nconst settings = {\\n loader: {\\n modules: [\\n \'#src/core\',\\n \'#src/models\',\\n \'#src/serializers\',\\n \'#spec/factories\',\\n ],\\n },\\n};\\n
import RecordSet from \'#src/core/recordSet.js\';\\n\\nimport util from \'util\';\\n\\nutil.inspect.styles.record = \'blue\';\\nconst customInspectSymbol = Symbol.for(\'nodejs.util.inspect.custom\');\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model, fields, validations) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n model.validations = validations || [];\\n model.indexes = [];\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n unique: false,\\n inspectable: true,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n ...fieldOptions,\\n type: \'number\',\\n allowEmpty: false,\\n unique: true,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const {\\n type: dataType,\\n allowEmpty,\\n defaultValue,\\n unique,\\n inspectable,\\n } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldTypeChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n let fieldChecker = fieldTypeChecker;\\n if (unique) {\\n model.indexes.push(fieldName);\\n\\n const uniqueChecker = value =>\\n !Model.indexedInstances[name][fieldName].has(value);\\n\\n fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value);\\n }\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue, inspectable };\\n });\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = model.indexes.reduce((acc, index) => {\\n acc[index] = new Map();\\n return acc;\\n }, {});\\n }\\n\\n Object.entries(Object.getOwnPropertyDescriptors(model.prototype)).forEach(\\n ([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n }\\n );\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker, defaultValue }]) => {\\n this[fieldName] = data[fieldName] ?? defaultValue;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n this.constructor.validations?.forEach(validation => {\\n if (!validation(this, Model.instances[modelName])) {\\n throw new Error(\\n `Invalid data for ${modelName} model: ${JSON.stringify(this)}`\\n );\\n }\\n });\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n this.constructor.indexes.forEach(index => {\\n Model.indexedInstances[modelName][index].set(this[index], this);\\n });\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].id.get(id);\\n }\\n\\n static findBy(fieldAndValue) {\\n const entries = Object.entries(fieldAndValue);\\n if (entries.length !== 1)\\n throw new Error(\'findBy method must receive a single field/value pair\');\\n\\n const [fieldName, value] = entries[0];\\n return this.indexedInstances[this.name][fieldName].get(value);\\n }\\n\\n [customInspectSymbol](depth, options) {\\n const modelName = this.constructor.name;\\n const id = `0x${this.id.toString(16).slice(0, 8).padStart(8, \'0\')}`;\\n const inspectable = Object.entries(this.constructor.fields).reduce(\\n (obj, [fieldName, { inspectable }]) => {\\n if (inspectable) obj[fieldName] = this[fieldName];\\n return obj;\\n },\\n {}\\n );\\n\\n if (depth <= 1) return options.stylize(`{ ${modelName} #${id} }`, \'record\');\\n\\n return `${options.stylize(`[${modelName} #${id}]: {`, \'record\')}${util\\n .inspect({ ...inspectable }, { ...options, depth: depth - 1 })\\n .slice(1, -1)}${options.stylize(\'}\', \'record\')}`;\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
export default class Serializer {\\n static prepare(serializer, serializableAttributes) {\\n serializer.serializableAttributes = [];\\n\\n serializableAttributes.forEach((attribute) => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias) return this.subject[attributeName];\\n if (typeof alias === \\"string\\") return this.subject[alias];\\n if (typeof alias === \\"function\\")\\n return alias(this.subject, this.options);\\n return undefined;\\n },\\n });\\n });\\n }\\n\\n constructor(subject, options = {}) {\\n this.subject = subject;\\n this.options = options;\\n }\\n\\n static serialize(subject, options) {\\n return new this(subject, options).serialize();\\n }\\n\\n static serializeArray(subjects, options) {\\n return subjects.map((subject) => this.serialize(subject, options));\\n }\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n\\nconst sequenceSymbol = Symbol(\'sequence\');\\n\\nconst isSequence = value =>\\n value && typeof value[sequenceSymbol] === \'function\';\\n\\nexport default class Factory {\\n static factoryMap = new Map();\\n static modelMap = new Map();\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const modelName = model.name;\\n\\n const factoryBase = {};\\n\\n Object.keys(base).forEach(key => {\\n const value = base[key];\\n const getter = isSequence(value) ? value[sequenceSymbol] : () => value;\\n Object.defineProperty(factoryBase, key, {\\n get: getter,\\n enumerable: true,\\n });\\n });\\n\\n if (!Factory.modelMap.has(modelName))\\n Factory.modelMap.set(modelName, factory);\\n\\n Object.defineProperty(factory, \'build\', {\\n value: function (...desiredTraits) {\\n const data = { ...factoryBase };\\n\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n },\\n });\\n }\\n\\n static sequence = (fn = n => n) => {\\n let i = 0;\\n const sequence = () => fn(i++);\\n return { [sequenceSymbol]: sequence };\\n };\\n\\n static build(model, ...desiredTraits) {\\n return Factory.modelMap.get(model).build(...desiredTraits);\\n }\\n\\n static buildArray(model, count, ...desiredTraits) {\\n return Array.from({ length: count }, () =>\\n Factory.build(model, ...desiredTraits)\\n );\\n }\\n\\n static clear(model) {\\n Model.instances[model] = [];\\n Model.indexedInstances[model] = new Map();\\n Model.getterCache[model] = {};\\n }\\n\\n static clearAll() {\\n Model.instances = {};\\n Model.indexedInstances = {};\\n Model.getterCache = {};\\n }\\n}\\n
import { readdir } from \'node:fs/promises\';\\n\\nimport settings from \'#src/config/settings.js\';\\n\\nconst capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);\\n\\nconst autoload = async () => {\\n const moduleMap = new Map();\\n\\n for (const path of settings.loader.modules) {\\n // Read each directory (this requires a path relative to the project root)\\n const moduleFiles = await readdir(path.replace(/^#/, \'./\'));\\n\\n for (const moduleFile of moduleFiles) {\\n // Convert the file name to a module name (e.g., post.js -> Post)\\n const moduleName = capitalize(moduleFile.split(\'.\')[0]);\\n\\n if (!moduleMap.has(moduleName)) {\\n // Dynamically import the module and add it to the map\\n const module = await import(`${path}/${moduleFile}`);\\n moduleMap.set(moduleName, module.default);\\n } else throw new Error(`Duplicate class name: ${moduleName}`);\\n }\\n }\\n\\n // Convert the map to an object and return it, so that it can be exported\\n return Object.fromEntries(moduleMap.entries());\\n};\\n\\nconst modules = await autoload();\\n\\nexport default { ...modules, settings };\\n
import repl from \'node:repl\';\\nimport modules from \'#src/scripts/autoload.js\';\\n\\n// Start the REPL server\\nconst replServer = repl.start();\\n// Set up a history file for the REPL\\nreplServer.setupHistory(\'repl.log\', () => {});\\n\\n// Add the autoloaded modules to the REPL context\\nObject.entries(modules).forEach(([moduleName, module]) => {\\n replServer.context[moduleName] = module;\\n});\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n [\'authorId\', \'number\'],\\n ]);\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(\\n this,\\n [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true, inspectable: false }],\\n ],\\n [record => record.email.includes(\'@\')]\\n );\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n super.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }],\\n [\'author\', (post, options) => {\\n const author = post.author;\\n const result = { name: author.fullName };\\n if (options.showEmail) result.email = author.email;\\n return result;\\n }]\\n ]);\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostPreviewSerializer extends Serializer {\\n static {\\n super.prepare(this, [\\n \'title\',\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n month: \'short\',\\n day: \'numeric\',\\n year: \'numeric\'\\n });\\n }],\\n [\'author\', post => post.author.fullName],\\n [\'url\', post => `/posts/${post.id}`]\\n ]);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nconst idSequence = Factory.sequence();\\n\\nconst base = {\\n id: idSequence,\\n name: \'Author\',\\n surname: \'Authorson\',\\n email: \'author@authornet.io\',\\n};\\n\\nexport default class AuthorFactory extends Factory {\\n static {\\n super.prepare(this, Author, base);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Post #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n content: \'Post content\',\\n};\\n\\nconst traits = {\\n published: {\\n publishedAt: new Date(),\\n },\\n unpublished: {\\n publishedAt: null,\\n },\\n};\\n\\nexport default class PostFactory extends Factory {\\n static {\\n super.prepare(this, Post, base, traits);\\n }\\n}\\n
Last updated: January 16, 2025 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This article is part of a series, following Modeling complex JavaScript object field validation. It\'s highly recommended to read the previous articles to get the full context. The whole series is more of a show & tell hoping to inspire you to start that advanced…","guid":"https://www.30secondsofcode.org/js/s/complex-object-autoloading-console","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-01-15T16:00:00.683Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/digital-nomad-14-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/digital-nomad-14-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object field validation","url":"https://www.30secondsofcode.org/js/s/complex-object-field-validation","content":"This article is part of a series, picking up where Modeling complex JavaScript object factories left off. If you haven\'t read the previous installments yet, I recommend taking a look at them first. This series is more of a show & tell hoping to inspire you to build your own JavaScript projects.
\\nWe previously explored how create robust object models, records and collections, however, we\'ve not yet touched on how to validate the fields of these objects. This time around, we\'ll be focusing on adding constraints to individual fields, including type checking, empty and default values, and more.
\\nThis time around, we won\'t be making any changes to the existing directory structure. We\'ll only make some changes to our Model
class and update individual models
to include field validation.
src/\\n├── core/\\n│ ├── model.js\\n│ ├── recordSet.js\\n│ ├── serializer.js\\n│ └── factory.js\\n├── models/\\n│ ├── author.js\\n│ └── post.js\\n└── serializers/\\n ├── postSerializer.js\\n └── postPreviewSerializer.js\\nspec/\\n└── factories/\\n ├── authorFactory.js\\n └── postFactory.js\\n
You can find an implementation refresher in the code summary of the previous article, if you need to catch up.
\\nI find the semantics of relational databases and ActiveRecord to be quite nice for dictating object structure, so I\'ll loosely base my implementation on them. Thus, fields can have types, decide whether they can be empty or not, and define a default value.
\\nNotice the loosely part in my previous statement. The following setup is not 100% compatible with any relational database. It hinges on similar ideas and may transfer rather well in certain scenarios, however, it leaves a lot of wiggle room to the user, which may be easily abused, causing incompatible behavior.
\\nRelational databases have a small subset of data types that you can use. Instead of going down their specific implementation route, we\'ll adapt this concept to JavaScript. What we want is to be able to define a fields as a string
, number
, boolean
, or date
. We\'ll also throw in an any
type for good measure, as there are scenarios where more complex data may need to be stored.
We\'ll focus solely on scalar types, skipping vector types entirely. The latter would require a few implementation tweaks, that are left up to the reader to explore. Notice, however, that if you find yourself defining field constraints to work with JavaScript objects, you\'re most likely doing something wrong and need to go design a model for that object, instead.
\\nTo make this work, we\'ll have to add our field definitions as an argument to the prepare
method of our Model
class. This will allow us to define the fields and their constraints when creating a new model. To make the code a little more readable, we\'ll follow the example Serializer
, where each definition is either a string or an array.
What exactly is this field definition going to be? you may be asking. Simply put, single strings will be field names that will not be type-checked (any
type), whereas arrays will contain the field name as the first element and the data type as the second. We\'ll later see how the second value can be extended to include more constraints.
Let\'s start with the prepare
method in the Model
and make the necessary changes.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, fields) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n const dataType = isAlias\\n ? field[1]\\n : fieldName === \'id\'\\n ? \'number\'\\n : \'any\';\\n\\n let fieldChecker;\\n if (dataType === \'any\') fieldChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n fieldChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n fieldChecker = value => value instanceof Date;\\n else\\n throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n model.fields[fieldName] = { fieldChecker };\\n });\\n\\n Object.entries(\\n Object.getOwnPropertyDescriptors(model.prototype),\\n ).forEach(([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n });\\n }\\n\\n // ...\\n}\\n
Some design decisions in this code snippet will come to make sense in the next couple of sections, as we add more constraints to our fields. If they feel overengineered at this point, it\'s because I stripped down the final implementation to its individual steps.
\\nThis seems like a lot, but we\'ve just added a loop to store the field definitions in the model. We\'ve also added a fieldChecker
to each field, which will be used to validate the field when creating a new record.
Yes, but these definitions don\'t do anything yet. Right. Let\'s go ahead and update the Model
class one more time. This time around, we\'ll make sure to use our new fields
definition in the constructor
. We\'l loop over this definition, find the fields we want to add to the record and validate them.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker }]) => {\\n this[fieldName] = data[fieldName] ?? null;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n // ...\\n}\\n
If you\'re not familiar with the nullish coalescing (??
) operator, I highly suggest reading up on the previous article on the topic. In this case, it\'s used instead of the logical OR (||
) operator to default to null
if the field is missing. This accounts for falsy values, such as 0
, which would be overridden by the logical OR operator.
Notice how we use this.constructor
to access the fields
definition. Again, we\'re using the fact that this
resolves to the calling subclass, which is the model we\'re creating an instance of. We\'re also using the data
object to populate the fields of the record. If a field is missing, we\'ll default to null
. This will practically break the type-checking if any value is empty, which we\'ll deal with in a minute.
Let\'s update our Post
and Author
models to include some field definitions. We\'ll also need to remove almost all logic from our constructor
s, as it\'s now handled in the Model
class.
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this, [\\n [\'name\', \'string\'],\\n [\'surname\', \'string\'],\\n [\'email\', \'string\'],\\n ]);\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n // ...\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, [\\n [\'title\', \'string\'],\\n [\'content\', \'string\'],\\n [\'publishedAt\', \'date\'],\\n [\'authorId\', \'number\'],\\n ]);\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n // ...\\n}\\n
Having set up type-checking, we need to address the elephant in the room: null
. We need to allow some fields to be empty, but we want to control this on the field definition level. This begs the question: how?
First off, we need to decide the default setup for any field. We\'ve already decided to default to an any
type, but we need to decide if we should or shouldn\'t allow empty values. The path of least friction dictates that we should allow fields to be empty by default, while allowing the constraint to be explicitly defined.
To define said constraint, we\'ll make sure our field definition can handle objects as the second argument, thus allowing us to define more than just the data type. This object will consist of type
and allowEmpty
keys.
Let\'s update our prepare
method to handle this.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, fields) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = { type: \'any\', allowEmpty: true };\\n if (fieldName === \'id\')\\n fieldOptions = { type: \'number\', allowEmpty: false };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else fieldOptions.type = field[1];\\n }\\n\\n const { type: dataType, allowEmpty } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n model.fields[fieldName] = { fieldChecker };\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
That\'s literally all we need to do. On top of type checking, we now have a check for the allowEmpty
constraint, slightly altering the fieldChecker
function. This will allow us to define fields that can be empty, while still enforcing the type constraint.
Let\'s make a couple of updates to our model field definitions.
\\nimport Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this, [\\n [\'name\', { type: \'string\', allowEmpty: false },\\n [\'surname\', \'string\'],\\n [\'email\', \'string\'],\\n ]);\\n }\\n\\n // ...\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', \'date\'],\\n [\'authorId\', \'number\'],\\n ]);\\n }\\n\\n // ...\\n}\\n
Thus far, we\'ve worked under the assumption of null
being the empty value. However, we may want to default to a different value. This is especially useful for fields that are not allowed to be empty, but we still want to have a default value.
Same as before, we\'ll extend the definition to include a defaultValue
key, allowing us to define a default value for each field, which will be used if the field is empty. This requires a small change in the prepare
method and an update in the constructor
.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model, fields) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n type: \'number\',\\n allowEmpty: false,\\n defaultValue: null,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const { type: dataType, allowEmpty, defaultValue } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue };\\n });\\n\\n // ...\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker }]) => {\\n this[fieldName] = data[fieldName] ?? defaultValue;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n // ...\\n}\\n
This time around, we\'ll update our Post
with a default value for the publishedAt
field.
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n [\'authorId\', \'number\'],\\n ]);\\n }\\n\\n // ...\\n}\\n
const post = new Post({ id: 1, title: \'My post\' });\\n// {\\n// id: 1,\\n// title: \'My post\',\\n// content: \'null,\\n// publishedAt: 2025-01-09T00:00:00.000Z,\\n// authorId: null\\n// }\\n
A more complex constraint that is often required is field uniqueness. This is especially useful for fields like id
, which should be unique across all records of a model. This also opens up the potential for multiple indices on a model, which can be useful for searching records by different fields.
id
We\'ll start by constraining the id
field to ensure that it\'s unique across all records of a model. This needs yet another small change in the prepare
method of our Model
.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, fields) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n type: \'number\',\\n allowEmpty: false,\\n defaultValue: null,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const { type: dataType, allowEmpty, defaultValue } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldTypeChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n let fieldChecker = fieldTypeChecker;\\n if (fieldName === \'id\') {\\n const uniqueChecker = value =>\\n !Model.indexedInstances[name].has(value);\\n\\n fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value);\\n }\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue };\\n });\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
Such a small change, yet it allows us to constrain the id
field to be unique across all records of a model. Our indexedInstances
storage is leveraged to check for uniqueness, taking advantage of the performance of the Map
data structure. This will prevent us from creating multiple records with the same id
with a minimal performance overhead at record creation.
Our current implementation only allows for a single index, which is the id
field. We previously used this field to store the records in the indexedInstances
map. We\'ll need to update this structure to allow for multiple indices.
While we\'re at it, let\'s add a unique
constraint to the field definition, which will allow us to define fields that should be unique across all records of a model. Naturally, id
will be unique by default, but we can now define other fields as unique as well.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, fields) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n model.indexes = [];\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n unique: false,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n type: \'number\',\\n allowEmpty: false,\\n defaultValue: null,\\n unique: true,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const { type: dataType, allowEmpty, defaultValue, unique } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldTypeChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n let fieldChecker = fieldTypeChecker;\\n if (unique) {\\n model.indexes.push(fieldName);\\n\\n const uniqueChecker = value =>\\n !Model.indexedInstances[name][fieldName].has(value);\\n\\n fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value);\\n }\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue };\\n });\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = model.indexes.reduce((acc, index) => {\\n acc[index] = new Map();\\n return acc;\\n }, {});\\n }\\n\\n // ...\\n }\\n\\n // ...\\n}\\n
We\'ve only covered single field indices in this implementation. Compound indices, while possible, require significant work to implement. You can give this a go, if you feel up to the challenge, but it felt like diminishing returns for this already long article.
\\nThis change breaks our Model
class, as find
and the constructor
need to account for the change in the underlying data structures. Let\'s make the necessary changes.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker }]) => {\\n this[fieldName] = data[fieldName] ?? null;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n this.constructor.indexes.forEach(index => {\\n Model.indexedInstances[modelName][index].set(this[index], this);\\n });\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].id.get(id);\\n }\\n\\n // ...\\n}\\n
That\'s it! Same performance and logic, more flexibility and we can add uniqueness constraints. Let\'s update our Author
model to make sure the email
field is unique.
import Model from \'#src/core/model.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this, [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true }],\\n ]);\\n }\\n\\n // ...\\n}\\n
The astute reader will notice that this specific setup will only allow for a single author to have an empty (null
) email. This is a side effect of combining the allowEmpty
and unique
constraints. In most practical use cases, this is a non-issue, as we\'d only want to enforce uniqueness on non-empty fields.
Our find
method is perfect when querying records by their id
. But, having multiple indices, we may as well add a findBy
method to leverage these data structures. This will allow us to query records by any field that has a unique constraint.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n static findBy(fieldAndValue) {\\n const entries = Object.entries(fieldAndValue);\\n if (entries.length !== 1)\\n throw new Error(\'findBy method must receive a single field/value pair\');\\n\\n const [fieldName, value] = entries[0];\\n return this.indexedInstances[this.name][fieldName].get(value);\\n }\\n\\n // ...\\n}\\n
And let\'s see it in action for our Author
model, querying using the email
field.
const author = new Author({ id: 1, name: \'John\', email: \'john@authornet.io\' });\\n\\nAuthor.findBy({ email: \'john@authornet.io\' });\\n// Author { id: 1, name: \'John\', email: \'john@authornet.io\' }\\n
We\'ve covered the basics of individual field validation, but what if we want to apply custom validation conditions? This is hard to cover by the current setup, however, it\'s possible to implement a model-wide custom validation system.
\\nAfter tinkering with different approaches, I settled on a third argument to the prepare
method, which allows an optional array of validator functions to be passed. These, in turn, will be executed on the newly created record, in the constructor
, to ensure it meets the custom validation criteria.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n\\n static prepare(model, fields, validations) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n model.validations = validations || [];\\n model.indexes = [];\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n unique: false,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n type: \'number\',\\n allowEmpty: false,\\n defaultValue: null,\\n unique: true,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const { type: dataType, allowEmpty, defaultValue, unique } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldTypeChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n let fieldChecker = fieldTypeChecker;\\n if (unique) {\\n model.indexes.push(fieldName);\\n\\n const uniqueChecker = value =>\\n !Model.indexedInstances[name][fieldName].has(value);\\n\\n fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value);\\n }\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue };\\n });\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = model.indexes.reduce((acc, index) => {\\n acc[index] = new Map();\\n return acc;\\n }, {});\\n }\\n\\n // ...\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker, defaultValue }]) => {\\n this[fieldName] = data[fieldName] ?? defaultValue;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n this.constructor.validations?.forEach(validation => {\\n if (!validation(this, Model.instances[modelName])) {\\n throw new Error(\\n `Invalid data for ${modelName} model: ${JSON.stringify(this)}`\\n );\\n }\\n });\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n this.constructor.indexes.forEach(index => {\\n Model.indexedInstances[modelName][index].set(this[index], this);\\n });\\n }\\n\\n // ...\\n}\\n
We can then add a custom validation to our Author
model, ensuring that the email
field contains an @
symbol as proper email validation is hard.
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(\\n this,\\n [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true }],\\n ],\\n [record => record.email.includes(\'@\')]\\n );\\n }\\n\\n // ...\\n}\\n
That\'s a wrap! Our models can finally be used to store structured data. We\'ve covered type constraints, emptiness constraints, default values, field uniqueness, and custom validators. This is a great starting point for a more complex system, which can be extended in many ways, or used to interface with a relational database.
\\nAs the project grows towards its final form, I want to address a couple more topics before the series is over. Stay tuned for the next installment and, if you feel like it, drop a reaction or a comment in the GitHub discussion, linked below. Until next time!
\\nThe complete implementation is summarized below, as is traditional by now. This includes all the changes we\'ve made to the Model
class, as well as the changes to the Author
and Post
models, and all previous implementations.
You can also browse through the Code Reference on GitHub.
\\nimport RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model, fields, validations) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n model.validations = validations || [];\\n model.indexes = [];\\n model.fields = {};\\n\\n [\'id\', ...fields].forEach(field => {\\n const isAlias = Array.isArray(field);\\n const fieldName = isAlias ? field[0] : field;\\n\\n if (!fieldName || model.fields[fieldName])\\n throw new Error(`Invalid field name in ${name}`);\\n\\n let fieldOptions = {\\n type: \'any\',\\n allowEmpty: true,\\n defaultValue: null,\\n unique: false,\\n };\\n if (fieldName === \'id\')\\n fieldOptions = {\\n type: \'number\',\\n allowEmpty: false,\\n defaultValue: null,\\n unique: true,\\n };\\n\\n if (isAlias) {\\n if (typeof field[1] === \'string\') fieldOptions.type = field[1];\\n else if (typeof field[1] === \'object\')\\n fieldOptions = { ...fieldOptions, ...field[1] };\\n else\\n throw new Error(\\n `Invalid field definition for ${fieldName} in ${name}`\\n );\\n }\\n\\n const { type: dataType, allowEmpty, defaultValue, unique } = fieldOptions;\\n\\n let dataTypeChecker;\\n if (dataType === \'any\') dataTypeChecker = value => value !== null;\\n else if ([\'string\', \'boolean\', \'number\'].includes(dataType))\\n dataTypeChecker = value => typeof value === dataType;\\n else if (dataType === \'date\')\\n dataTypeChecker = value => value instanceof Date;\\n else throw new Error(`Invalid data type for ${fieldName} in ${name}`);\\n\\n const fieldTypeChecker = allowEmpty\\n ? value => value === null || dataTypeChecker(value)\\n : dataTypeChecker;\\n\\n let fieldChecker = fieldTypeChecker;\\n if (unique) {\\n model.indexes.push(fieldName);\\n\\n const uniqueChecker = value =>\\n !Model.indexedInstances[name][fieldName].has(value);\\n\\n fieldChecker = value => fieldTypeChecker(value) && uniqueChecker(value);\\n }\\n\\n model.fields[fieldName] = { fieldChecker, defaultValue };\\n });\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = model.indexes.reduce((acc, index) => {\\n acc[index] = new Map();\\n return acc;\\n }, {});\\n }\\n\\n Object.entries(Object.getOwnPropertyDescriptors(model.prototype)).forEach(\\n ([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n }\\n );\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Object.entries(this.constructor.fields).forEach(\\n ([fieldName, { fieldChecker, defaultValue }]) => {\\n this[fieldName] = data[fieldName] ?? defaultValue;\\n\\n if (!fieldChecker(this[fieldName])) {\\n throw new Error(\\n `Invalid value for field ${fieldName} in ${modelName}: ${this[fieldName]}`\\n );\\n }\\n }\\n );\\n\\n this.constructor.validations?.forEach(validation => {\\n if (!validation(this, Model.instances[modelName])) {\\n throw new Error(\\n `Invalid data for ${modelName} model: ${JSON.stringify(this)}`\\n );\\n }\\n });\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n this.constructor.indexes.forEach(index => {\\n Model.indexedInstances[modelName][index].set(this[index], this);\\n });\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].id.get(id);\\n }\\n\\n static findBy(fieldAndValue) {\\n const entries = Object.entries(fieldAndValue);\\n if (entries.length !== 1)\\n throw new Error(\'findBy method must receive a single field/value pair\');\\n\\n const [fieldName, value] = entries[0];\\n return this.indexedInstances[this.name][fieldName].get(value);\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
export default class Serializer {\\n static prepare(serializer, serializableAttributes) {\\n serializer.serializableAttributes = [];\\n\\n serializableAttributes.forEach((attribute) => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias) return this.subject[attributeName];\\n if (typeof alias === \\"string\\") return this.subject[alias];\\n if (typeof alias === \\"function\\")\\n return alias(this.subject, this.options);\\n return undefined;\\n },\\n });\\n });\\n }\\n\\n constructor(subject, options = {}) {\\n this.subject = subject;\\n this.options = options;\\n }\\n\\n static serialize(subject, options) {\\n return new this(subject, options).serialize();\\n }\\n\\n static serializeArray(subjects, options) {\\n return subjects.map((subject) => this.serialize(subject, options));\\n }\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n\\nconst sequenceSymbol = Symbol(\'sequence\');\\n\\nconst isSequence = value =>\\n value && typeof value[sequenceSymbol] === \'function\';\\n\\nexport default class Factory {\\n static factoryMap = new Map();\\n static modelMap = new Map();\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const modelName = model.name;\\n\\n const factoryBase = {};\\n\\n Object.keys(base).forEach(key => {\\n const value = base[key];\\n const getter = isSequence(value) ? value[sequenceSymbol] : () => value;\\n Object.defineProperty(factoryBase, key, {\\n get: getter,\\n enumerable: true,\\n });\\n });\\n\\n if (!Factory.modelMap.has(modelName))\\n Factory.modelMap.set(modelName, factory);\\n\\n Object.defineProperty(factory, \'build\', {\\n value: function (...desiredTraits) {\\n const data = { ...factoryBase };\\n\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n },\\n });\\n }\\n\\n static sequence = (fn = n => n) => {\\n let i = 0;\\n const sequence = () => fn(i++);\\n return { [sequenceSymbol]: sequence };\\n };\\n\\n static build(model, ...desiredTraits) {\\n return Factory.modelMap.get(model).build(...desiredTraits);\\n }\\n\\n static buildArray(model, count, ...desiredTraits) {\\n return Array.from({ length: count }, () =>\\n Factory.build(model, ...desiredTraits)\\n );\\n }\\n\\n static clear(model) {\\n Model.instances[model] = [];\\n Model.indexedInstances[model] = new Map();\\n Model.getterCache[model] = {};\\n }\\n\\n static clearAll() {\\n Model.instances = {};\\n Model.indexedInstances = {};\\n Model.getterCache = {};\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this, [\\n [\'title\', { type: \'string\', allowEmpty: false }],\\n [\'content\', \'string\'],\\n [\'publishedAt\', { type: \'date\', defaultValue: new Date() }],\\n [\'authorId\', \'number\'],\\n ]);\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(\\n this,\\n [\\n [\'name\', { type: \'string\', allowEmpty: false }],\\n [\'surname\', \'string\'],\\n [\'email\', { type: \'string\', unique: true }],\\n ],\\n [record => record.email.includes(\'@\')]\\n );\\n }\\n\\n constructor(data) {\\n super(data);\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n super.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }],\\n [\'author\', (post, options) => {\\n const author = post.author;\\n const result = { name: author.fullName };\\n if (options.showEmail) result.email = author.email;\\n return result;\\n }]\\n ]);\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostPreviewSerializer extends Serializer {\\n static {\\n super.prepare(this, [\\n \'title\',\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n month: \'short\',\\n day: \'numeric\',\\n year: \'numeric\'\\n });\\n }],\\n [\'author\', post => post.author.fullName],\\n [\'url\', post => `/posts/${post.id}`]\\n ]);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nconst idSequence = Factory.sequence();\\n\\nconst base = {\\n id: idSequence,\\n name: \'Author\',\\n surname: \'Authorson\',\\n email: \'author@authornet.io\',\\n};\\n\\nexport default class AuthorFactory extends Factory {\\n static {\\n super.prepare(this, Author, base);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Post #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n content: \'Post content\',\\n};\\n\\nconst traits = {\\n published: {\\n publishedAt: new Date(),\\n },\\n unpublished: {\\n publishedAt: null,\\n },\\n};\\n\\nexport default class PostFactory extends Factory {\\n static {\\n super.prepare(this, Post, base, traits);\\n }\\n}\\n
Last updated: January 9, 2025 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This article is part of a series, picking up where Modeling complex JavaScript object factories left off. If you haven\'t read the previous installments yet, I recommend taking a look at them first. This series is more of a show & tell hoping to inspire you to…","guid":"https://www.30secondsofcode.org/js/s/complex-object-field-validation","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-01-08T16:00:00.869Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/planning-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/planning-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object factories","url":"https://www.30secondsofcode.org/js/s/complex-object-factories","content":"This article is part of a series, following Modeling complex JavaScript object serialization. It may still make sense on its own, but it\'s highly recommended to read the previous articles first. This series is more of a show & tell with the aim to inspire you to build more advanced JavaScript projects.
\\nIn the past four installments, we\'ve created models, queries, scopes and serialization for our ActiveRecord-inspired project. As the project grows larger, we might find a need to test our code. However, mocking things is hard, especially the more complex our objects get. This is where factories come in.
\\nOn top of what we previously built, we\'ll add a new class in the core
directory, called Factory
. We\'ll also start populating the spec
directory with factories
for our models.
src/\\n├── core/\\n│ ├── model.js\\n│ ├── recordSet.js\\n│ ├── serializer.js\\n│ └── factory.js\\n├── models/\\n│ ├── author.js\\n│ └── post.js\\n└── serializers/\\n ├── postSerializer.js\\n └── postPreviewSerializer.js\\nspec/\\n└── factories/\\n ├── authorFactory.js\\n └── postFactory.js\\n
As usual, you can find a refresher of the entire implementation so far in the code summary from the previous article.
\\nIn the world of testing, we often need to create mock data to test our code. This is often done with fixtures, static pieces of data that we can use to test our code. However, fixtures can be cumbersome to maintain, especially as our objects grow complex. They also often break or get outdated, making seemingly unrelated tests fail.
\\nThis is where factories come in. Factories are dynamic pieces of code that can generate mock data for our tests. They can be as simple or as complex as we need them to be, and they can be updated easily when our objects change. As far as I can tell, they are based on the Factory pattern from the Gang of Four book.
\\nFor this particular implementation, I\'m going to loosely base my factories on the Factory Bot gem for Ruby. The reason is that I\'m rather familiar with this library and I like using it, so that\'s where I\'m drawing my inspiration from.
\\nTo create our factories, we\'ll start by creating a Factory
class in the core
directory. As we usually do, we\'ll start simple and build from there.
First off, we will follow the by-now familiar pattern of using static initialization blocks to create a registry of factories. This will allow us to easily access our factories from anywhere in our code.
\\nBut what will the registry hold? I hear you asking. If we look to our models, each model needs some initial data
to pass to its constructor
. That\'s what we\'ll call the base of the factory. Then, to allow for more complex objects, we\'ll also add traits to our factories. Traits are essentially modifiers that can be applied to the base data to create customized objects.
Finishing up the setup, we\'ll also need to pass the model to the factory, so it knows what kind of object it\'s supposed to create. Putting everything together, we arrive at our first draft of the prepare
static method.
export default class Factory {\\n static factoryMap = new Map();\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const factoryName = factory.name;\\n\\n // Store the factory in the factory map\\n if (!Factory.factoryMap[factoryName])\\n Factory.factoryMap.set(factoryName, {\\n model,\\n base,\\n traits,\\n });\\n }\\n}\\n
In some cases, you may want customizable traits with parameters. For example, you might want a few interdependent fields combined in a trait, but you may want to pass some parameter to create them. While this isn\'t exactly covered in this article, the next few sections cover a similar pattern, via the use of functions.
\\nGiven our setup, we can create factories for our models. They won\'t do much yet, but let\'s get them set up anyway.
\\nimport Factory from \'#src/core/factory.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nconst base = {\\n id: 1,\\n name: \'Author\',\\n surname: \'Authorson\',\\n email: \'author@authornet.io\',\\n};\\n\\nexport default class AuthorFactory extends Factory {\\n static {\\n Factory.prepare(this, Author, base);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nconst base = {\\n id: 1,\\n title: \'Post title\',\\n content: \'Post content\',\\n};\\n\\nconst traits = {\\n published: {\\n publishedAt: new Date(),\\n },\\n unpublished: {\\n publishedAt: null,\\n },\\n};\\n\\nexport default class PostFactory extends Factory {\\n static {\\n Factory.prepare(this, Post, base, traits);\\n }\\n}\\n
Notice how we\'re using the prepare
method to set up our factories. We\'re passing the factory itself, the model it\'s supposed to create, the base data for the model, and any traits we want to apply to the base data. As Author
is a little simpler for the time being, we only need to pass the base data, skipping the traits entirely.
Now that we have our factories set up, we can start building objects with them. We\'ll add a static build
method to our Factory
class that will take any number of traits and apply them to the base data.
export default class Factory {\\n // ...\\n\\n static build(...desiredTraits) {\\n const factoryName = this.name;\\n const { model, base, traits } = Factory.factoryMap.get(factoryName);\\n\\n // Merge the base and traits\\n const data = Object.assign(\\n {},\\n base,\\n ...desiredTraits.map((trait) => traits[trait])\\n );\\n\\n return new model(data);\\n }\\n}\\n
What we\'ve done is fairly simple, as you can probably tell. As the this
context of the function refers to the factory subclass, we can use this.name
to get the factory name, look up the factory in the registry, and merge the base data with any desired traits. We then pass the resulting data to the model\'s constructor and return the new object.
const post = PostFactory.build();\\n// Post { id: 1, title: \'Post title\', content: \'Post content\' }\\n\\nconst publishedPost = PostFactory.build(\'published\');\\n// Post {\\n// id: 1,\\n// title: \'Post title\',\\n// content: \'Post content\',\\n// publishedAt: 2025-01-02T00:00:00.000Z\\n//}\\n
Our traits are nice and all, but it\'s inefficient to have to define a trait for every special case in our codebase. While they provide the benefit of composition, they can still end up becoming a bit unwieldy.
\\nTo solve this, we can allow the build
method to accept objects and functions. Given an object, the method will merge it with the base data. Given a function, it will call the function with the base data and return the result.
export default class Factory {\\n // ...\\n\\n static build(...desiredTraits) {\\n const factoryName = this.name;\\n const { model, base, traits } = Factory.factoryMap.get(factoryName);\\n\\n const data = { ...base };\\n\\n // Merge the base and traits\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n }\\n}\\n
What can we do with it? Well, we can create very specific objects, as needed. For example, let\'s pass a custom title to our post and a function to generate a random email for our author.
\\nconst post = PostFactory.build(\'unpublished\', { title: \'Custom title\' });\\n// Post {\\n// id: 1,\\n// title: \'Custom title\',\\n// content: \'Post content\',\\n// publishedAt: null\\n// }\\n\\nconst author = AuthorFactory.build(\\n { name: \'John\' },\\n data => ({ email: `${data.name.toLowerCase()}@authornet.io` }\\n);\\n// Author {\\n// id: 1,\\n// name: \'John\',\\n// surname: \'Authorson\',\\n// email: \'john@authornet.io\'\\n// }\\n
Some of you might be wondering why I didn\'t choose to make the customized object contain functions, so that email
can be specified as a function, instead of passing two parameters. This is a design choice I stand by, as the cost of calling a function for each property can easily pile up. Instead, most of the time, we can get away with passing a function that generates multiple properties at once. At most, we\'ll end up with an object and a function for each factory call.
In some cases, we might want to clear out objects, to make sure they don\'t interfere with our tests. This is especially useful when we\'re counting records or checking relationships, for example.
\\nWe can add two new methods, clear
and clearAll
, to our Factory
class to handle this. These methods will simply access the static variables (instances
, indexedInstances
and getterCache
) of the Model
class and reset them.
import Model from \'#src/core/model.js\';\\n\\nexport default class Factory {\\n // ...\\n\\n static clear(model) {\\n Model.instances[model] = [];\\n Model.indexedInstances[model] = new Map();\\n Model.getterCache[model] = {};\\n }\\n\\n static clearAll() {\\n Model.instances = {};\\n Model.indexedInstances = {};\\n Model.getterCache = {};\\n }\\n}\\n
And here they are in action, clearing instances created by the PostFactory
.
PostFactory.build();\\nPost.all.length; // 1\\nFactory.clear(\'Post\');\\nPost.all.length; // 0\\n
As you\'re well aware by this point, convenience methods are a staple of my coding style. I definitely dislike having to find the appropriate factory to call every time I need to create an object. I\'d much rather call a method on the Factory
class itself, specifying the model I want to create.
This requires a little bit of setup first. We\'ll have to add a modelMap
to our Factory
class, which will allow us to look up the factory for a given model. And, instead of having a static build
method on the Factory class, we\'ll make sure to define it per factory subclass.
export default class Factory {\\n static modelMap = new Map();\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const modelName = model.name;\\n\\n // Store the factory in the model map\\n if (!Factory.modelMap.has(modelName))\\n Factory.modelMap.set(modelName, factory);\\n\\n Object.defineProperty(factory, \'build\', {\\n value: function (...desiredTraits) {\\n const data = { ...base };\\n\\n // Merge the base and traits\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n }\\n });\\n }\\n\\n // Remove the static `build` method\\n}\\n
So far, we\'ve ended up exactly where we were before. Only difference is that we\'ve moved the build
method to the factory subclass, using Object.defineProperty()
, which allows us to define a property on the factory subclass. Notice how we can use the factory
variable to pass the subclass and the value
descriptor to define the method (in contrast with the get
descriptor we used before).
Let\'s go ahead and add our convenience methods now. We\'ll add a build
method to the Factory
class that will look up the appropriate factory for the model and call the build
method on it. And, while we\'re at it, let\'s add a buildArray
method that will allow us to create an array of objects.
export default class Factory {\\n // ...\\n\\n static build(modelName, ...desiredTraits) {\\n return Factory.modelMap.get(model).build(...desiredTraits);\\n }\\n\\n static buildArray(modelName, count, ...desiredTraits) {\\n return Array.from({ length: count }, () =>\\n Factory.build(model, ...desiredTraits)\\n );\\n }\\n}\\n
With these methods in place, we can now create multiple objects at once, and all from the Factory
class itself.
const author = Factory.build(\'Author\', { email: \'\' });\\n// Author { id: 1, name: \'Author\', surname: \'Authorson\', email: \'\' }\\n\\nconst posts = Factory.buildArray(\'Post\', 3, { content: null }, \'unpublished\');\\n// [\\n// Post { id: 1, title: \'Post title\', content: null, publishedAt: null },\\n// Post { id: 1, title: \'Post title\', content: null, publishedAt: null },\\n// Post { id: 1, title: \'Post title\', content: null, publishedAt: null }\\n// ]\\n
As you may have noticed in the previous example, we\'re always creating objects with the same id
. This is fine for most cases, but it can become a problem when we need to test objects with unique identifiers. This is where sequences come in. Sequences are a way to generate unique values for our objects.
Ok, we\'re going in the deep end now. We need a way to easily define a sequence inside a factory subclass and assign it to the base data. The sequence must generate a new value each time, yet it must be passed into the base
definition. This is a bit tricky, but we can do it, using some advanced JavaScript features.
After trying a few different approaches, I\'ve settled on hiding the complexity behind the prepare
method, while exposing a static sequence
method on the factory subclass. This method will return something, which can then be picked up by the prepare
method and set up an appropriate build
method for the subclass.
What sort of something should it return? is the million dollar question. I\'ve landed yet again on using the Symbol
built-in object. This is because Symbol
is unique and immutable, and it can be used as a key in an object. This allows us to return an object with a unique characteristic that we can then easily look up in the prepare
method.
const sequenceSymbol = Symbol(\'sequence\');\\n\\n// ...\\n
Notice how we keep the Symbol
outside of the class and we don\'t expose it in any way. This means that this is an immutable, unique value that can only ever be known to the Factory
class. This way, we ensure no one can mess with our sequences.
Let\'s go ahead and define the sequence
method on our factory subclass now.
// ...\\n\\nconst isSequence = value =>\\n value && typeof value[sequenceSymbol] === \'function\';\\n\\nexport default class Factory {\\n // ...\\n\\n static sequence = (fn = n => n) => {\\n let i = 0;\\n const sequence = () => fn(i++);\\n return { [sequenceSymbol]: sequence };\\n };\\n}\\n
A simple closure does the trick here. We\'ll allow the caller to pass a function to transform the sequence value, and we\'ll return an object with a [sequenceSymbol]
key that contains the sequence function. This way, we can easily look up the sequence function in the prepare
method, using our new isSequence
helper function.
If you\'re unfamiliar with JavaScript\'s closures or simply need a refresher, I highly recommend reading this article on the subject.
\\nFinally, we can update the prepare
method to look for sequences in the base data and apply them to the object.
export default class Factory {\\n // ...\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const modelName = model.name;\\n\\n const factoryBase = {};\\n\\n Object.keys(base).forEach(key => {\\n const value = base[key];\\n const getter = isSequence(value) ? value[sequenceSymbol] : () => value;\\n Object.defineProperty(factoryBase, key, {\\n get: getter,\\n enumerable: true,\\n });\\n });\\n\\n if (!Factory.modelMap.has(modelName))\\n Factory.modelMap.set(modelName, factory);\\n\\n Object.defineProperty(factory, \'build\', {\\n value: function (...desiredTraits) {\\n const data = { ...factoryBase };\\n\\n // Merge the base and traits\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n },\\n });\\n }\\n}\\n
That looks fairly complex at this point. Let\'s unpack the changes we\'ve made. Using the isSequence
helper function, we can check if a key in the base
is a sequence. In that case, we\'ll use the function returned by the sequence to generate the value. Otherwise, we\'ll use the value as is.
In order to make this work, we\'ll have to build a new base from the base
. However, using Object.defineProperty()
will normally return non-enumerable values, which will break our spread operation (...
) later down the line. to fix that, we\'ll have to use enumerable: true
to make sure the properties are enumerable.
I understand that there may be a need to use sequences in traits, too. However, I\'ve decided to keep things simple for now. If you need sequences in your traits, it\'s relatively easy to implement this functionality yourself. Give it a go, I\'m sure you\'ll find it\'s a fun challenge!
\\nFinally, we can go ahead and use our sequences in our factories. Let\'s update our PostFactory
to include a sequence for the id
. While we\'re at it, we can also add a sequence for the title
field, too. We\'ll also update AuthorFactory
to include a sequence for the id
, too.
import Factory from \'#src/core/factory.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Post #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n content: \'Post content\',\\n};\\n\\nconst traits = {\\n published: {\\n publishedAt: new Date(),\\n },\\n unpublished: {\\n publishedAt: null,\\n },\\n};\\n\\nexport default class PostFactory extends Factory {\\n static {\\n Factory.prepare(this, Post, base, traits);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nconst idSequence = Factory.sequence();\\n\\nconst base = {\\n id: idSequence,\\n name: \'Author\',\\n surname: \'Authorson\',\\n email: \'author@authornet.io\',\\n};\\n\\nexport default class AuthorFactory extends Factory {\\n static {\\n Factory.prepare(this, Author, base);\\n }\\n}\\n
With these changes in place, we can now create objects with unique attribute values.
\\nconst author = AuthorFactory.build();\\nconst posts = Factory.buildArray(\\n \'Post\', 3, \'unpublished\', { authorId: author.id }\\n);\\n// [\\n// Post { id: 0, title: \'Post #0\', authorId: 0 },\\n// Post { id: 1, title: \'Post #1\', authorId: 0 },\\n// Post { id: 2, title: \'Post #2\', authorId: 0 }\\n// ]\\n
Notice how all of our posts have unique identifiers and titles, as they use the sequence we\'ve defined in the factory. Oh, and I\'ve snuck in a relationship in this example, too. This last bit worked all along, but we never cared to check, as we were too busy building our factories!
\\nWe\'ve come a long way in this series. We\'ve built models, queries, scopes, serialization, and now factories. This latest system allows us to easily create complex objects for our tests, and we\'ve even added sequences to generate unique values for our objects. Oh, and customization is a breeze, too!
\\nThere are a few bits and pieces that I\'ve implemented or have left out in previous implementation, as well as some problems you may face in the real world using such a setup. Stay tuned for future installments, where I\'ll cover these topics in more detail!
\\nLast but not least, here\'s a summary of the complete implementation. This includes all new classes, as well as previous one, so you can pick up where you left off next time.
\\nimport RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n Object.entries(\\n Object.getOwnPropertyDescriptors(model.prototype),\\n ).forEach(([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n });\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].get(id);\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
export default class Serializer {\\n static prepare(serializer, serializableAttributes) {\\n serializer.serializableAttributes = [];\\n\\n serializableAttributes.forEach((attribute) => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias) return this.subject[attributeName];\\n if (typeof alias === \\"string\\") return this.subject[alias];\\n if (typeof alias === \\"function\\")\\n return alias(this.subject, this.options);\\n return undefined;\\n },\\n });\\n });\\n }\\n\\n constructor(subject, options = {}) {\\n this.subject = subject;\\n this.options = options;\\n }\\n\\n static serialize(subject, options) {\\n return new this(subject, options).serialize();\\n }\\n\\n static serializeArray(subjects, options) {\\n return subjects.map((subject) => this.serialize(subject, options));\\n }\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n\\nconst sequenceSymbol = Symbol(\'sequence\');\\n\\nconst isSequence = value =>\\n value && typeof value[sequenceSymbol] === \'function\';\\n\\nexport default class Factory {\\n static factoryMap = new Map();\\n static modelMap = new Map();\\n\\n static prepare(factory, model, base = {}, traits = {}) {\\n const modelName = model.name;\\n\\n const factoryBase = {};\\n\\n Object.keys(base).forEach(key => {\\n const value = base[key];\\n const getter = isSequence(value) ? value[sequenceSymbol] : () => value;\\n Object.defineProperty(factoryBase, key, {\\n get: getter,\\n enumerable: true,\\n });\\n });\\n\\n if (!Factory.modelMap.has(modelName))\\n Factory.modelMap.set(modelName, factory);\\n\\n Object.defineProperty(factory, \'build\', {\\n value: function (...desiredTraits) {\\n const data = { ...factoryBase };\\n\\n desiredTraits.forEach(trait => {\\n if (typeof trait === \'string\')\\n Object.assign(data, traits[trait]);\\n else if (typeof trait === \'object\')\\n Object.assign(data, trait);\\n else if (typeof trait === \'function\')\\n Object.assign(data, trait(data));\\n });\\n\\n return new model(data);\\n },\\n });\\n }\\n\\n static sequence = (fn = n => n) => {\\n let i = 0;\\n const sequence = () => fn(i++);\\n return { [sequenceSymbol]: sequence };\\n };\\n\\n static build(model, ...desiredTraits) {\\n return Factory.modelMap.get(model).build(...desiredTraits);\\n }\\n\\n static buildArray(model, count, ...desiredTraits) {\\n return Array.from({ length: count }, () =>\\n Factory.build(model, ...desiredTraits)\\n );\\n }\\n\\n static clear(model) {\\n Model.instances[model] = [];\\n Model.indexedInstances[model] = new Map();\\n Model.getterCache[model] = {};\\n }\\n\\n static clearAll() {\\n Model.instances = {};\\n Model.indexedInstances = {};\\n Model.getterCache = {};\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n this.publishedAt = data.publishedAt;\\n this.authorId = data.authorId;\\n }\\n\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.name = data.name;\\n this.surname = data.surname;\\n this.email = data.email;\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }],\\n [\'author\', (post, options) => {\\n const author = post.author;\\n const result = { name: author.fullName };\\n if (options.showEmail) result.email = author.email;\\n return result;\\n }]\\n ]);\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostPreviewSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n month: \'short\',\\n day: \'numeric\',\\n year: \'numeric\'\\n });\\n }],\\n [\'author\', post => post.author.fullName],\\n [\'url\', post => `/posts/${post.id}`]\\n ]);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nconst idSequence = Factory.sequence();\\n\\nconst base = {\\n id: idSequence,\\n name: \'Author\',\\n surname: \'Authorson\',\\n email: \'author@authornet.io\',\\n};\\n\\nexport default class AuthorFactory extends Factory {\\n static {\\n Factory.prepare(this, Author, base);\\n }\\n}\\n
import Factory from \'#src/core/factory.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nconst idSequence = Factory.sequence();\\nconst titleSequence = Factory.sequence(n => `Post #${n}`);\\n\\nconst base = {\\n id: idSequence,\\n title: titleSequence,\\n content: \'Post content\',\\n};\\n\\nconst traits = {\\n published: {\\n publishedAt: new Date(),\\n },\\n unpublished: {\\n publishedAt: null,\\n },\\n};\\n\\nexport default class PostFactory extends Factory {\\n static {\\n Factory.prepare(this, Post, base, traits);\\n }\\n}\\n
Last updated: January 2, 2025 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This article is part of a series, following Modeling complex JavaScript object serialization. It may still make sense on its own, but it\'s highly recommended to read the previous articles first. This series is more of a show & tell with the aim to inspire you to…","guid":"https://www.30secondsofcode.org/js/s/complex-object-factories","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2025-01-01T16:00:00.644Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/tranquil-desktop-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/tranquil-desktop-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object serialization","url":"https://www.30secondsofcode.org/js/s/complex-object-serialization","content":"This article is part of a series, picking up where Modeling complex JavaScript object scopes left off. Make sure to read previous articles in the series before continuing. The entire series is more of a show & tell, aiming to inspire you to use more advanced JavaScript features and patterns in your projects.
\\nIn the last installment, we covered a lot of ground towards making our model queries reusable. Our Model
and RecordSet
classes are now capable of handling complex queries, and we can even compose them in a way. Our two models, Post
and Author
are now fully functional and our code is well-optimized. However, we still have one problem to solve - serialization.
As usual, before diving into the code, let\'s check the directory structure of our project. This time around, we\'re adding a new class, Serializer
, as well as a new directory, called serializers
.
src/\\n├── core/\\n│ ├── model.js\\n│ ├── recordSet.js\\n│ └── serializer.js\\n├── models/\\n│ ├── author.js\\n│ └── post.js\\n└── serializers/\\n ├── postSerializer.js\\n └── postPreviewSerializer.js\\n
If you need a code refresher without going into all the details, check out the code summary from the previous article.
\\nIf you\'re not familiar, serialization is the conversion of an object into a format that can be stored or transmitted. In our case, we want to convert our Post
and Author
objects into a format that can be sent to the client. This format is usually JSON.
I\'m not exactly sure how common my mileage is in this matter, but I\'m quite used to serializers being somewhat separate from models. Oftentimes, a serializer will be akin to a view into the model, rather than a one-to-one representation. This is especially true, when design patterns such as decorators or presenters come into play, completely transforming the data.
\\nTo that effect, my approach to serialization is a very flexible one. A serializer should only be given a subject to serialize and an options object. Then, given some standard rules and configuration of attributes, it should be able to produce the desired result without a lot of fuss or poking around too much in the underlying structure.
\\nI\'m pretty sure the original inspiration for this approach comes from the active_model_serializers
Ruby gem for Rails. Notice that I\'m simply drawing inspiration from it, not trying to replicate it exactly in JavaScript.
In this particular example, we\'ll be working with two serializers essentially for the same model, Post
. The first one, PostSerializer
, will be used to serialize a full post, while the second one, PostPreviewSerializer
, will be used to serialize a preview of a post, for example as part of a list of posts.
const author = new Author({\\n id: 1,\\n name: \'John\',\\n surname: \'Doe\',\\n email: \'j.doe@authornet.io\'\\n});\\n\\nconst post = new Post({\\n id: 1,\\n title: \'Hello, World!\',\\n content: \'Lorem ipsum dolor sit amet.\',\\n publishedAt: new Date(\'2024-12-01\') ,\\n authorId: 1\\n});\\n\\n// Sample of a serialized post (PostSerializer)\\n// {\\n// title: \'Hello, World!\',\\n// content: \'<p>Lorem ipsum dolor sit amet.</p>\',\\n// date: \'Sun, Dec 01, 2024\',\\n// author: {\\n// name: \'John Doe\',\\n// email: \'j.doe@authornet.io\'\\n// }\\n// }\\n\\n// Sample of a serialized post preview (PostPreviewSerializer)\\n// {\\n// title: \'Hello, World!\',\\n// date: \'Dec 01, 2024\',\\n// author: \'John Doe\',\\n// url: \'/posts/1\'\\n// }\\n
Each model has its own attributes, which, if you remember, are getter functions. As these attributes are easily accessible and often cached in memory, we can outline which ones we want to serialize.
\\nTo create our Serializer
class, we\'ll start by defining a simple constructor
. It will accept a subject
to serialize and an options
object. Via the options object, we can pass additional configuration, which can be used later down the line. For now, we\'ll just store the subject and options in the class instance.
export default class Serializer {\\n constructor(subject, options = {}) {\\n this.subject = subject;\\n this.options = options;\\n }\\n}\\n
Before we can do anything meaningful with this class, we\'ll need to define a way to dictate the serializable attributes. Drawing inspiration from previous implementations, we can utilize our old friend, the static initialization block. Using this trick, we can define a prepare
function, much like we did with the Model
class.
But what will this function do? you may be asking. Simply put, it will accept the serializer subclass and an array of attribute names. Then, it will store these attributes in a static property of the subclass. This way, we can easily access them later on.
\\nexport default class Serializer {\\n static prepare(serializer, attributes) {\\n serializer.serializableAttributes = attributes;\\n }\\n\\n // ...\\n}\\n
Looks simple, right? Let\'s go ahead and define a serialize
method, which will return an object with the serialized attributes. We\'ll use the serializableAttributes
property to filter out the attributes we want to serialize.
export default class Serializer {\\n // ...\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this.subject[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
In this post, I won\'t be covering the conversion to a JSON string, as it\'s a trivial task, using JSON.stringify()
. Instead, I\'ll focus on the structure of the serialized object.
Notice the use of this.constructor
instead of Serializer
. This is because we want to access the static property of the subclass, not the core serializer class. We then retrieve the attribute value from the subject and store it in the accumulator object.
With all of this boilerplate out of the way, we can finally define our PostSerializer
. For starters, we\'ll have it return the title
, content
and publishedAt
attributes of a post.
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\'title\', \'content\', \'publishedAt\']);\\n }\\n}\\n
Putting it to the test, we can now serialize a post. Sure, it\'s not much, but it\'s a start. Notice how the specified attributes are serialized exactly as defined in the Post
model.
// Considering the post object from the previous example\\n\\nnew PostSerializer(post).serialize();\\n// {\\n// title: \'Hello, World!\',\\n// content: \'Lorem ipsum dolor sit amet.\',\\n// publishedAt: \'2024-12-01T00:00:00.000Z\'\\n// }\\n
Sometimes, it\'s necessary to rename attributes when serializing them. One could argue that you can simply add more attribute getters to your models and that would work to an extent, but it would make the models unwieldy and harder to maintain.
\\nInstead, we can introduce the concept of aliases. Aliases will be provided as a two-element array, where the first element is the name of the serialized attribute and the second element is the name of the attribute getter.
\\nLet\'s modify the Serializer
\'s prepare
method to account for that.
export default class Serializer {\\n static prepare(serializer, attributes) {\\n serializer.serializableAttributes = [];\\n\\n attributes.forEach(attribute => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias) return this.subject[attributeName];\\n return this.subject[alias];\\n },\\n });\\n });\\n }\\n\\n // ...\\n}\\n
There are a few things to unpack here. The prepare
method can accept a mix of strings and arrays. If an array is detected, the first element is used as the serialized attribute name, while the second element is used as the attribute getter name. If no second element is provided, the serialized attribute name is used as the getter name.
But the fun part comes later in the function, where Object.defineProperty()
is used to define a getter for the attribute. If an alias is detected, the getter will return the value of the alias attribute, otherwise it will return the value of the attribute itself.
As you may have noticed, this change doesn\'t really affect the seralize
method just yet. We\'ll have to make an adjustment there, too. Instead of relying on the subject
, we now have each of the serializableAttributes
as a getter on the serializer instance.
export default class Serializer {\\n // ...\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
Finally, we can go ahead and update PostSerializer
with the new alias feature. We\'ll rename the publishedAt
attribute to date
.
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n \'content\',\\n [\'date\', \'publishedAt\']\\n ]);\\n }\\n}\\n
// Considering the post object from the previous examples\\n\\nnew PostSerializer(post).serialize();\\n// {\\n// title: \'Hello, World!\',\\n// content: \'Lorem ipsum dolor sit amet.\',\\n// date: \'2024-12-01T00:00:00.000Z\'\\n// }\\n
Expanding upon the alias system, one could easily imagine a scenario where you\'d want to serialize an attribute that doesn\'t exist on the model. This could be a calculated attribute, a combination of other attributes, or even a completely unrelated value. It may also be a value that requires some additional processing before being serialized, or even be dependent on the options passed to the serializer.
\\nSame as before, let\'s update our prepare
method to handle a second argument that is a function. This function will be called with the serializer\'s subject and options, and should return the value to be serialized.
export default class Serializer {\\n static prepare(serializer, attributes) {\\n serializer.serializableAttributes = [];\\n\\n attributes.forEach(attribute => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias)\\n return this.subject[attributeName];\\n if (typeof alias === \'string\')\\n return this.subject[alias];\\n if (typeof alias === \'function\')\\n return alias(this.subject, this.options);\\n return undefined;\\n },\\n });\\n });\\n }\\n\\n // ...\\n}\\n
Luckily, this time around, our serialize
method doesn\'t need any changes. We can now define a custom attribute in our PostSerializer
. Let\'s do that for our date
attribute, which will format the publishedAt
attribute into a human-readable date. While we\'re at it, let\'s wrap our content
in a <p>
tag, too.
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }]\\n ]);\\n }\\n}\\n
// Considering the post object from the previous examples\\n\\nnew PostSerializer(post).serialize();\\n// {\\n// title: \'Hello, World!\',\\n// content: \'<p>Lorem ipsum dolor sit amet.</p>\',\\n// date: \'Sun, Dec 1, 2024\'\\n// }\\n
Alright, cool, but we also want to include the author\'s information in there, too. As you may remember, the Author
is a separate model, and the author
attribute of the Post
model is a relationship. We shouldn\'t have any trouble using it to serialize the author\'s name and email. But wait! The Author
has a fullName
attribute, which we\'d like to use instead of name
and surname
separately.
Let\'s spice it up even more, by making sure that the email depends on the options passed to the serializer. If the showEmail
option is set to true
, we\'ll include the email in the serialized object, otherwise we\'ll just include the name.
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }],\\n [\'author\', (post, options) => {\\n const author = post.author;\\n const result = { name: author.fullName };\\n if (options.showEmail) result.email = author.email;\\n return result;\\n }]\\n ]);\\n }\\n}\\n
Looks cool, right? Let\'s see it put to action in an example.
\\n// Considering the post object from the previous examples\\n\\nnew PostSerializer(post).serialize();\\n// {\\n// title: \'Hello, World!\',\\n// content: \'<p>Lorem ipsum dolor sit amet.</p>\',\\n// date: \'Sun, Dec 1, 2024\',\\n// author: {\\n// name: \'John Doe\'\\n// }\\n// }\\n\\nnew PostSerializer(post, { showEmail: true }).serialize();\\n// {\\n// title: \'Hello, World!\',\\n// content: \'<p>Lorem ipsum dolor sit amet.</p>\',\\n// date: \'Sun, Dec 1, 2024\',\\n// author: {\\n// name: \'John Doe\',\\n// email: \'j.doe@authornet.io\'\\n// }\\n// }\\n
Serializers don\'t have a cache, as you may be aware. Be sure to move as much of the heavy lifting to your model\'s attribute getters as possible. This way, you can avoid repeating costly calculations and keep your performance in check.
\\nAs we\'ve already seen, the serializer itself is not tied to the model. This means we can create another serializer with similar attributes, but different underlying implementations. Let\'s create a PostPreviewSerializer
that will serialize a post preview, as shown in the example at the beginning of the article.
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostPreviewSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n month: \'short\',\\n day: \'numeric\',\\n year: \'numeric\'\\n });\\n }],\\n [\'author\', post => post.author.fullName],\\n [\'url\', post => `/posts/${post.id}`]\\n ]);\\n }\\n}\\n
// Considering the post object from the previous examples\\n\\nnew PostPreviewSerializer(post).serialize();\\n// {\\n// title: \'Hello, World!\',\\n// date: \'Dec 1, 2024\',\\n// author: \'John Doe\'\\n// url: \'/posts/1\'\\n// }\\n
Wow, that really was fast! All of this setup really paid off. Notice how we couldn\'t have had the two serializers spit out these results without all the hard work that went into aliases and custom attributes. If we hadn\'t set these up, we would only have been able to have one date
format or a single author
structure via the model\'s calculated attributes. Oh, and forget customization via options!
Yes, technically, we could have hacked together a solution with different calculated attributes and just used the simple aliases or transformed the result afterwards, but all of this is far too much hassle. Besides, it\'s not very DRY or maintainable.
\\nYou\'re probably used to it by now, but I really love me some convenience methods. This time around, we\'ll keep it simple, adding two static methods to the Serializer
class, serialize
and serializeArray
. The former will create a new instance of the serializer and call the serialize
method, while the latter will do so for an array of subjects.
export default class Serializer {\\n // ...\\n\\n static serialize(subject, options) {\\n return new this(subject, options).serialize();\\n }\\n\\n static serializeArray(subjects, options) {\\n return subjects.map(subject => this.serialize(subject, options));\\n }\\n}\\n
I don\'t suppose it\'s necessary to show you how to use these methods, as they\'re pretty straightforward. All they do is provide us with some syntactic sugar, so we don\'t have to create a new instance of the serializer every time we want to serialize something.
\\nThis entry of the series was a little bit off the Rails (get it? because it\'s not so much about Ruby on Rails... never mind). While it may not seem like much, we\'ve made a huge leap in terms of reusability and flexibility. Our serializers are capable of handling complex attribute serialization, aliases, custom attributes, and even options. They can also hide a lot of complexity under the hood, so we don\'t need to dive into details.
\\nAs the codebase of this series is starting to get larger and more complex, we might need to address topics such as loading everything and testing the code. The next installments in the series will focus on these topics, so stay tuned!
\\nYou didn\'t think I\'d leave you without a code reference for this article, did you? Make sure to bookmark this for future reference, as we\'ll need it in the next installment.
\\nimport RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n Object.entries(\\n Object.getOwnPropertyDescriptors(model.prototype),\\n ).forEach(([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n });\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].get(id);\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
export default class Serializer {\\n static prepare(serializer, serializableAttributes) {\\n serializer.serializableAttributes = [];\\n\\n serializableAttributes.forEach((attribute) => {\\n const isAlias = Array.isArray(attribute);\\n const attributeName = isAlias ? attribute[0] : attribute;\\n\\n if (!attributeName) return;\\n\\n const alias = isAlias ? attribute[1] : null;\\n\\n serializer.serializableAttributes.push(attributeName);\\n\\n Object.defineProperty(serializer.prototype, attributeName, {\\n get() {\\n if (!isAlias) return this.subject[attributeName];\\n if (typeof alias === \\"string\\") return this.subject[alias];\\n if (typeof alias === \\"function\\")\\n return alias(this.subject, this.options);\\n return undefined;\\n },\\n });\\n });\\n }\\n\\n constructor(subject, options = {}) {\\n this.subject = subject;\\n this.options = options;\\n }\\n\\n static serialize(subject, options) {\\n return new this(subject, options).serialize();\\n }\\n\\n static serializeArray(subjects, options) {\\n return subjects.map((subject) => this.serialize(subject, options));\\n }\\n\\n serialize() {\\n return this.constructor.serializableAttributes.reduce(\\n (acc, attribute) => {\\n acc[attribute] = this[attribute];\\n return acc;\\n },\\n {},\\n );\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n this.publishedAt = data.publishedAt;\\n this.authorId = data.authorId;\\n }\\n\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.name = data.name;\\n this.surname = data.surname;\\n this.email = data.email;\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'content\', post => `<p>${post.content}</p>`],\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n weekday: \'short\',\\n year: \'numeric\',\\n month: \'short\',\\n day: \'numeric\'\\n });\\n }],\\n [\'author\', (post, options) => {\\n const author = post.author;\\n const result = { name: author.fullName };\\n if (options.showEmail) result.email = author.email;\\n return result;\\n }]\\n ]);\\n }\\n}\\n
import Serializer from \'#src/core/serializer.js\';\\n\\nexport default class PostPreviewSerializer extends Serializer {\\n static {\\n Serializer.prepare(this, [\\n \'title\',\\n [\'date\', post => {\\n const date = new Date(post.publishedAt);\\n return date.toLocaleDateString(\'en-US\', {\\n month: \'short\',\\n day: \'numeric\',\\n year: \'numeric\'\\n });\\n }],\\n [\'author\', post => post.author.fullName],\\n [\'url\', post => `/posts/${post.id}`]\\n ]);\\n }\\n}\\n
Last updated: December 26, 2024 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This article is part of a series, picking up where Modeling complex JavaScript object scopes left off. Make sure to read previous articles in the series before continuing. The entire series is more of a show & tell, aiming to inspire you to use more advanced…","guid":"https://www.30secondsofcode.org/js/s/complex-object-serialization","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-12-25T16:00:00.023Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/coffee-phone-tray-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/coffee-phone-tray-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object scopes","url":"https://www.30secondsofcode.org/js/s/complex-object-scopes","content":"This article is part of a series and picks up where the previous article, Modeling complex JavaScript object attributes & relationships, left off. If you haven\'t read it yet, I recommend you do so before continuing. The entire series is more of a show & tell, aiming to inspire you to use more advanced JavaScript features and patterns.
\\nIn the previous two installments, we\'ve covered the core of our implementation, with a Model
and RecordSet
class, as well as two models, Author
and Post
. We\'ve also implemented basic querying, attributes and relationships. This time around, we\'ll focus on object scoping, a way to quickly retrieve a subset of objects from a collection.
Before we dive into the code, let\'s take a look at the current directory structure:
\\nsrc/\\n├── core/\\n│ ├── model.js\\n│ └── recordSet.js\\n└── models/\\n ├── author.js\\n └── post.js\\n
This time around, we won\'t be making any changes to the structure, but rather to the existing files. Let\'s get started!
\\nAgain, you can find an implementation refresher in the code summary of the previous article. We\'ll be building on top of that codebase in this article.
\\nActiveRecord has a concept called scopes, which are predefined queries that can be reused. These are usually defined in a model and can be chained together to create more complex queries. They provide convenient ways to make your code DRY (Don\'t Repeat Yourself) and more reusable.
\\nDue to the way, we\'ve implemented our querying system, scopes can be easily defined, yet the syntax won\'t be too similar to that of ActiveRecord. I honestly don\'t mind this too much, due to the fact that it may make searching for scope usage a little easier.
\\nFirst things first, however, let\'s define a scope for our Post
model. The obvious use case here is to find posts that are published. If you recall, this can be done by checking the publishedAt
attribute.
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n // ...\\n static published(records) {\\n const now = new Date();\\n return records.where({ publishedAt: d => d < now });\\n }\\n}\\n
You may have noticed that I\'m not relying on the this
keyword, nor am I using the model to retrieve the records, which may strike you as strange. I\'ll come back round to explain this design choice in due time.
If you were to explain what this scope does, you\'d definitely use the word filtering. While the distinction isn\'t necessarily an important one, it\'s better to build step-by-step understanding. Let\'s see this filter in action:
\\nconst posts = [\\n new Post({ id: 1, publishedAt: new Date(\'2024-12-01\') }),\\n new Post({ id: 2, publishedAt: new Date(\'2024-12-15\') }),\\n new Post({ id: 3, publishedAt: new Date(\'2024-12-20\') }),\\n];\\n\\n// Supposing the current date is 2024-12-19\\nconst publishedPosts = Post.published(Post.all);\\n// [ Post { id: 1 }, Post { id: 2 } ]\\n
Apart from filtering records, we may also want to sort them. Before we do that, however, I\'d like to add an order
method to our RecordSet
class. It\'s not much more than an alias for Array.prototype.sort()
, but I prefer naming things explicitly. This way we can search for record set operations more easily in larger codebases, instead of deciphering the type of the caller.
export default class RecordSet extends Array {\\n // ...\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n}\\n
Notice that this order
method can subtly handle plain arrays and RecordSet
s. This subtlety may come in handy when combined with pluck
or Array.prototype.map()
and can save us from a few headaches. We can also expose this method in the Model
class, same as we did with where
.
export default class Model {\\n // ...\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n}\\n
Now that we have defined the order
method, let\'s define a scope for our Post
model that sorts posts by their publishedAt
attribute, newest first.
export default class Post extends Model {\\n // ...\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n}\\n
This scope isn\'t a filtering scope, but rather a sorting one. We expect the same amount of records back, but in a different order. Let\'s see this scope in action:
\\n// Consider the posts from the previous sample and the same current date\\nconst newestPosts = Post.byNew(Post.all);\\n// [ Post { id: 3 }, Post { id: 2 }, Post { id: 1 } ]\\n
Now that we have some scopes defined, we can try chaining them together. This will allow us to create more complex queries by combining multiple scopes. As scopes are named functions, complex logic can be collapsed into a few keywords, helping future maintainers understand the code more easily.
\\nChaining two scopes is relatively simple. We need only call the first scope and pass the result to the second scope. Let\'s chain the published
and byNew
scopes together:
// Consider the posts from the previous samples and the same current date\\nconst publishedPosts = Post.published(Post.all);\\n// [ Post { id: 1 }, Post { id: 2 } ]\\nconst newestPublishedPosts = Post.byNew(publishedPosts);\\n// [ Post { id: 2 }, Post { id: 1 } ]\\n
Ok, this is all well and good, but it\'s not particularly sustainable. Suppose we had half a dozen scopes, readability would quickly deteriorate, dragging maintainability down with it. We can do better!
\\nIf you noticed that the strange decisions to pass a records
argument to the scopes, you\'re about to find out why. This decision allows us to create a simpler chaining system in the Model
class itself. All we\'ll need is a scope
method that takes a list of scope names and applies them in order.
export default class Model {\\n // ...\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n}\\n
Finally, this
comes into play. Remember that in the context of a static
method in the model, it refers to the calling class, i.e. the model itself. This way, all scopes can start with all
records and build up from there.
Let\'s see how this new scope
method can make the previous example less verbose:
// Consider the posts from the previous samples and the same current date\\nconst newestPublishedPosts = Post.scope(\'published\', \'byNew\');\\n// [ Post { id: 2 }, Post { id: 1 } ]\\n
Under the hood, the exact same code is executed, but we\'ve made it easier to read and search for. This is a good example of how a small change can make a big difference in the long run.
\\nThere may be advanced cases, where you\'d want to specify the records the scope is applied on, instead of the entire collection. Implementing a default scope to replace all
is a relatively easy customization you can make yourself to handle such cases. Additionally, models can redefine all
themselves, if you feel like this is a better approach for your use case.
Before we wrap this up, I\'d like to make some minor adjustments around the codebase. In the published
scope, we didn\'t use the isPublished
calculated attribute, but relied on the publishedAt
data attribute.
This might be prudent in some cases, as the current date may be slightly different for different records. However, in most cases, we\'d use the calculated attribute, as it\'s less work and milliseconds rarely matter. Let\'s adjust the published
scope accordingly:
export default class Post extends Model {\\n // ...\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n}\\n
This change is minor and seems like we\'re optimizing the code, but we\'re rather making it a little slower, if anything. Why is that? If you remember from the previous article, the isPublished
attribute is calculated from some data that exists on the model. The data attribute is essentially persisted in memory, while the calculated one isn\'t. This can come to bite us for more complex operations, larger datasets, or more frequent calls. Let\'s fix it!
We can cache calculated attributes, same as we\'ve done for model instances before. The Model
class can hold this cache, seamlessly populate and use it as needed. Remember that all of our data is considered immutable, so a cache will be safe to use.
export default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n Object.entries(Object.getOwnPropertyDescriptors(model.prototype)).forEach(\\n ([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n }\\n );\\n }\\n // ...\\n}\\n
This code looks intimidating even to me, not gonna lie. But it\'s the only change we\'ll have to make. In the prepare
method, we now create a WeakMap
for each getter function on the model. This map will cache the calculated attribute for each instance, making subsequent calls faster.
But how? you may be asking. We use Object.getOwnPropertyDescriptors()
to find all the getters on the model, by checking each descriptor\'s get
property. If it is a function, we first create a WeakMap
. If you\'re not familiar with this data structure, it\'s a map that doesn\'t prevent garbage collection of its keys. This is perfect for our use case, as we don\'t want to keep instances alive just because they have a calculated attribute and we may need to conserve memory.
Then, we redefine the getter function itself. This new getter function will first check if the instance has a cached value for the attribute. If it doesn\'t, it will use the descriptor\'s get
property to call the original getter function, calculate the value and cache it, using the record as the key.
Quite the elaborate party trick, right? Despite the complexity, this change allows us to calculate attributes only once per instance, which can be a huge performance boost for larger datasets or more complex calculations. And the best part is, we don\'t need to change anything on any of our models, as this works automatically!
\\nI use the term once per instance a little liberally here. In fact, due to garbage collection, we have little control over how long the cache will be kept alive. However, for most data-intensive and repeatable operations, this cache will be a huge performance boost.
\\nAs per previous installments, we continue our journey to implement an ActiveRecord-like pattern in JavaScript. This time around, we\'ve focused on making repeated queries more efficient and easier to read. We\'ve implemented scopes, which are predefined queries that can be chained together to create more complex queries. We\'ve also optimized our code by caching calculated attributes, which can be a huge performance boost for larger datasets or more complex calculations.
\\nWhile the core of the project is starting to take shape, we\'ve yet to address some more advanced features, which we\'ll cover in future installments. Stay tuned for more and keep on coding!
\\nAs per tradition, the complete implementation up until this point can be found below. This is a good place to pick up from in future installments.
\\nimport RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n static getterCache = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n\\n // Cache getters, using a WeakMap for each model/key pair\\n if (!Model.getterCache[name]) Model.getterCache[name] = {};\\n\\n Object.entries(\\n Object.getOwnPropertyDescriptors(model.prototype),\\n ).forEach(([key, descriptor]) => {\\n // Find getter functions, create the WeakMap, redefine the getter\\n if (typeof descriptor.get === \'function\') {\\n Model.getterCache[name][key] = new WeakMap();\\n Object.defineProperty(model.prototype, key, {\\n get() {\\n if (!Model.getterCache[name][key].has(this)) {\\n // This calls the getter function and caches the result\\n Model.getterCache[name][key].set(\\n this,\\n descriptor.get.call(this)\\n );\\n }\\n return Model.getterCache[name][key].get(this);\\n },\\n });\\n }\\n });\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static order(comparator) {\\n return this.all.order(comparator);\\n }\\n\\n static scope(...scopes) {\\n return scopes.reduce((acc, scope) => this[scope](acc), this.all);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].get(id);\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n order(comparator) {\\n return RecordSet.from(this.sort(comparator));\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n this.publishedAt = data.publishedAt;\\n this.authorId = data.authorId;\\n }\\n\\n static published(records) {\\n return records.where({ isPublished: true });\\n }\\n\\n static byNew(records) {\\n return records.order((a, b) => b.publishedAt - a.publishedAt);\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.name = data.name;\\n this.surname = data.surname;\\n this.email = data.email;\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
Last updated: December 19, 2024 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This article is part of a series and picks up where the previous article, Modeling complex JavaScript object attributes & relationships, left off. If you haven\'t read it yet, I recommend you do so before continuing. The entire series is more of a show & tell, aimi…","guid":"https://www.30secondsofcode.org/js/s/complex-object-scopes","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-12-18T16:00:00.270Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/digital-nomad-13-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/digital-nomad-13-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object attributes & relationships","url":"https://www.30secondsofcode.org/js/s/complex-object-attributes-relationships","content":"This is the second article in a series about a recent project I implemented, inspired by Ruby on Rails\' ActiveRecord pattern. It\'s more of an advanced show & tell, aiming to inspire readers to use more advanced JavaScript features and patterns. If you haven\'t read the first article, I recommend you start there: Modeling complex JavaScript object collections in memory.
\\nIn the previous article, we explored the core of modeling complex object collections in memory, by setting up the basis for a Model
and RecordSet
, which can help us manage and interact with our data. In this article, we\'ll continue our journey, focusing on attributes and relationships between models. We\'ll also make some optimizations to our implementation to improve performance towards the end.
As mentioned previously, the directory structure for our project is fairly simple. This time, we\'ll be adding a new model in our models
directory, along with some code in the existing core
directory files. Here\'s what the new structure looks like:
src/\\n├── core/\\n│ ├── model.js\\n│ └── recordSet.js\\n└── models/\\n ├── author.js\\n └── post.js\\n
If you need a refresher on the various implementations, you can check out the code summary from the previous article.
\\nIn the ActiveRecord pattern, models have attributes that represent the columns in the database. Models can also define methods which act very similarly to attributes, allowing us to retrieve data that may be the result of more complex operations on the model\'s data.
\\nImplementing this in JavaScript isn\'t particularly hard, but we may want to consider - yet again - the often underused JavaScript getter functions to seamlessly access object properties and methods alike. This decisions will also come in handy later.
\\nInstead of modifying our Post
model from before, let\'s create a brand new Author
model. The model will contain information about an author, such as a name, surname, and email address. We\'ll also store an id
for all our models (we\'ve already done this for the Post
model). We\'ll come back to that in a minute.
Here\'s what the Author
model looks like:
import Model from \'#src/core/model.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.name = data.name;\\n this.surname = data.surname;\\n this.email = data.email;\\n }\\n}\\n
This simple model definition has given us access to the id
, name
, surname
, and email
attributes. We will call these data attributes to differentiate them from calculated attributes, which we\'ll be defining next.
This is a semantics distinction rather than anything else. I only make this distinction to help the reader understand that some attributes are stored in the object itself (data attributes) while others are calculated from the object\'s data (calculated attributes).
\\nNow that we have a model with some data attributes, let\'s add a calculated attribute to our Author
model. For this instance, we want a fullName
attribute that concatenates the name
and surname
attributes.
To achieve this, we can use a getter function in the Author
model:
import Model from \'#src/core/model.js\';\\n\\nexport default class Author extends Model {\\n // ...\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n}\\n
Data attributes are quite versatile and allow us to hide complexity behind seemingly simple properties. In this example, we take care to handle the case where the surname
is empty, as we consider it optional for this model.
Other cases may require comparison with external data or data that is time-sensitive. Let\'s look at our Post
model for an example of a calculated attribute that depends on the current date. We\'ll first update its constructor to accept a publishedAt
attribute.
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n // ...\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n this.publishedAt = data.publishedAt;\\n }\\n}\\n
Then, we can create a calculated attribute to check if the post is published. To do this, we\'ll compare the publishedAt
attribute with the current date and return a boolean value.
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n // ...\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n}\\n
In the ActiveRecord pattern, models can have relationships with other models. These relationships can be one-to-one, one-to-many, or many-to-many. One-to-one and one-to-many relationships are the most common and are relatively easy to implement, while many-to-many relationships are a little trickier to deal with.
\\nWe\'ll largely do away with these conventions and, instead simplify relationships to single and multiple. A single relationship means that a model instance references a single instance of another model, while a multiple relationship means that a model instance references multiple instances of another model.
\\nSingle relationships are simple to implement. In our case, we\'ll add a single relationship from the Post
model to the Author
, meaning that each post will have exactly one author.
To make single relationships easier, we\'ll use the id
attribute we sneaked into our models earlier. We\'ll also update our Model
class with a static
find
method, that can retrieve and return a model instance by its id
.
export default class Model {\\n // ...\\n static find(id) {\\n return this.all.find(model => model.id === id);\\n }\\n}\\n
Remember, this
in the context of this method refers to the calling class, which means that Author.find(id)
will return an Author
instance, while Post.find(id)
will return a Post
instance.
Next, we can update our Post
model to include an authorId
attribute:
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n // ...\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n this.publishedAt = data.publishedAt;\\n this.authorId = data.authorId;\\n }\\n}\\n
Ok, now we have an authorId
attribute in our Post
model. But how do we get the actual Author
model from this authorId
? We can create a getter function in the Post
model to do this, using our find
method from before.
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n // ...\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
Technically, author
is yet another calculated attribute, but it\'s special - a calculated attribute that returns another model instance. This is the basis of a single relationship!
const author = new Author({\\n id: 1,\\n name: \'John\',\\n surname: \'Doe\',\\n email: \'j.doe@authornet.io\'\\n});\\n\\nconst post = new Post({\\n id: 1,\\n title: \'Hello, World!\',\\n content: \'This is my first post.\',\\n publishedAt: new Date(\'2024-12-08\'),\\n authorId: author.id\\n});\\n\\npost.author;\\n// Author { id: 1, name: \'John\', surname: \'Doe\', email: \'j.doe@authornet.io\' }\\npost.author.fullName; // \'John Doe\'\\n
As you may have noticed, I skipped adding the other side of the relationship in the previous section. This means that no Author
instance can easily retrieve all the posts written by that author.
This, however, is a case of a multiple relationship, where a model instance references multiple instances of another model. Author
instances also don\'t have any id
references to Post
instances. How can we solve this?
Thinking in a similar manner as before, a posts
relationship would simply be a calculated attribute. Its job is to retrieve all posts by an author. But we know the author\'s id
! And we also have a way to get all records that match specific criteria, via the where
method in the RecordSet
class!
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n // ...\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
Pretty simple, huh? With barely any extra code, we\'ve managed to create a relationship from the Author
model to the Post
model. Complex relationship chains can be built in a similar manner, by chaining calculated attributes together.
const author = new Author({\\n id: 1,\\n name: \'John\',\\n surname: \'Doe\',\\n email: \'j.doe@authornet.io\'\\n});\\n\\nconst post1 = new Post({\\n id: 1,\\n title: \'Hello, World!\',\\n content: \'This is my first post.\',\\n publishedAt: new Date(\'2024-12-08\'),\\n authorId: author.id\\n});\\nconst post2 = new Post({\\n id: 2,\\n title: \'Goodbye, World!\',\\n content: \'This is my last post.\',\\n publishedAt: new Date(\'2024-12-12\'),\\n authorId: author.id\\n});\\n\\nauthor.posts;\\n// [Post { id: 1}, Post { id: 2 }]\\n\\n// Supposing the current date is 2024-12-10\\nauthor.posts.where(post => post.isPublished);\\n// [Post { id: 1 }]\\n
I\'ve carefully avoided many-to-many relationships here, mainly to keep things neat and simple. If you were to model them, you\'d store an array of id
s in a model instance, then use a where
query to retrieve the related instances. The other side of the relationship would be pretty much the same as the posts
attribute in the Author
model, except you\'d combine your where
query with pluck
to get the related id
s.
One emergent behavior of this implementation is the ability to query models based on all their attributes, not just the data attributes. This can unlock a lot of potential for complex queries and relationships.
\\nCalculated attributes are implemented as getter functions, which means our where
method can directly query them any way we like. Same goes for pluck
and select
, as well as built-in array methods, such as Array.prototype.map()
.
// Consider the posts from the previous sample and the same current date\\nconst posts = Post.all;\\n\\nposts.where(post => post.isPublished);\\n// [Post { id: 1 }]\\n\\nposts.pluck(\'title\');\\n// [\'Hello, World!\', \'Goodbye, World!\']\\n\\nposts.map(post => post.isPublished);\\n// [true, false]\\n
Relationships can be used in queries, too. After all, they\'re simply calculated properties we\'ve given a special meaning to. This means we can query relationships just like any other attribute.
\\nconst authors = Author.all;\\n\\nauthors.where(author => author.posts.length > 1);\\n// Author { id: 1 }\\n\\nauthors.pluck(\'posts\');\\n// [[Post { id: 1 }, Post { id: 2 }]\\n\\nauthors.map(author => author.posts.length);\\n// [2]\\n
The astute reader may have noticed by this point that mostly any attribute displays the same qualities in terms of querying. This observation paves the way for relationships through intermediate models (e.g. comments belonging to a post belonging to an author can be queried through the Author
instance itself). ActiveRecord\'s :through
associations may come to mind here.
id
queriesOne thing you may have noticed is that we query records by id
quite a lot. In fact, it may be our most common operation. We\'ve already made record retrieval use a central storage on the Model
class, but what if we could do more?
What I\'ve done in my implementation is quite simple - I\'ve added a second, indexed storage, using Map
objects. Apart from instances
, I also have indexedInstances
, where each model has its own Map
and we can quickly retrieve a record by id.
For this to work, we also need to update the prepare
method, along with the constructor
of the Model
class. And don\'t forget find
! By changing its underlying implementation, we essentially optimize the performance of all queries that rely on the id
attribute.
export default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].get(id);\\n }\\n}\\n
This is a simple optimization that can greatly improve the performance of your application, especially if you\'re dealing with a large number of records.
\\nIn reality, in my project I didn\'t implement this exactly like that. The main difference is that I made the id
attribute a configurable set of attributes that each model can define for itself. This makes the code a little more complicated and doesn\'t depart a ton of value at this point, but you can easily figure it out for yourself.
In this second installment of our journey to implement an ActiveRecord-like pattern in JavaScript, we\'ve focused on modeling complex object attributes and relationships. While it may have sounded a little intimidating at first, it wasn\'t so bad, right? The power of JavaScript\'s object-oriented features and built-in methods can help you abstract complexity fairly easily.
\\nThere\'s a ton more to explore in this project, so stay tuned for the next installment where we\'ll dive into yet more advanced behavior, implementation details and optimizations. Until then, happy coding!
\\nYet again, here\'s the complete implementation up until this point in the series. You may want to use it as a reference point or to test the code yourself.
\\nimport RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n static indexedInstances = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n\\n // Create a map to speed up queries\\n if (!Model.indexedInstances[name]) {\\n Model.indexedInstances[name] = new Map();\\n }\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n // Store the instance in the instances and indexedInstances\\n Model.instances[modelName].push(this);\\n Model.indexedInstances[modelName].set(data.id, this);\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n\\n static find(id) {\\n return Model.indexedInstances[this.name].get(id);\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Author from \'#src/models/author.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n this.publishedAt = data.publishedAt;\\n this.authorId = data.authorId;\\n }\\n\\n get isPublished() {\\n return this.publishedAt <= new Date();\\n }\\n\\n get author() {\\n return Author.find(this.authorId);\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\nimport Post from \'#src/models/post.js\';\\n\\nexport default class Author extends Model {\\n static {\\n // Prepare storage for the Author model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super(data);\\n this.id = data.id;\\n this.name = data.name;\\n this.surname = data.surname;\\n this.email = data.email;\\n }\\n\\n get fullName() {\\n return this.surname ? `${this.name} ${this.surname}` : this.name;\\n }\\n\\n get posts() {\\n return Post.where({ authorId: this.id });\\n }\\n}\\n
Last updated: December 12, 2024 View on GitHub ·\\nJoin the discussion
","description":"ℹ Important This is the second article in a series about a recent project I implemented, inspired by Ruby on Rails\' ActiveRecord pattern. It\'s more of an advanced show & tell, aiming to inspire readers to use more advanced JavaScript features and patterns. If you haven\'t read…","guid":"https://www.30secondsofcode.org/js/s/complex-object-attributes-relationships","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-12-11T16:00:00.611Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/digital-nomad-15-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/digital-nomad-15-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Modeling complex JavaScript object collections in memory","url":"https://www.30secondsofcode.org/js/s/complex-object-collections-in-memory","content":"I\'ve been working with Ruby on Rails quite a lot lately. What I\'ve come to like about it the most may be its ActiveRecord ORM (Object-Relational Mapping). I won\'t go into detail about here, but I wanted to share a similar sort of concept I put together for this very website using JavaScript.
\\nThis is more of a show & tell, rather than a tutorial. It\'s fairly complex and quite advanced, so I won\'t be explaining every single detail. The hope is to inspire you to think about using more advanced JavaScript features and building some interesting things with them.
\\nWhat I wanted to accomplish with this project is to have a way to load a fairly large amount of plain objects, which are related to each other. Instead of JavaScript\'s nesting, referencing and moving around things, I wanted something simple and elegant.
\\nActiveRecord was my initial inspiration, but the implementation has delved a little bit away from it. Instead of relying on a database, my need was to do this on the fly, using memory. I also didn\'t have a need to update the data in the objects. What I was essentially after was a way to load a snapshot of data into memory, populate a bunch of models and then query them.
\\nBefore we move any further, I think it\'s wise to set up the directory structure, as well as some conventions for how files are loaded. I\'m using ESM (ECMAScript Modules) for this project, so I can use import
and export
statements. ESM also comes with the ability to define an imports
field in package.json
, which allows me to define aliases for my imports.
The overall directory structure looks like this:
\\nsrc/\\n├── core/\\n│ ├── model.js\\n│ └── recordSet.js\\n└── models/\\n └── post.js\\n
We\'ll come to expand upon this setup in future posts, but for now, this is all we need. The core
directory contains the core classes, while the models
directory contains the model classes.
I\'ve also set up the imports
field in package.json
to alias the src
directory. This way, files can be imported using the #src
alias, instead of having to use relative paths.
// package.json\\n{\\n \\"imports\\": {\\n \\"#src/*\\": [\\n \\"./src/*\\"\\n ]\\n }\\n}\\n
At the core of this project are two main classes: Model
, RecordSet
. In future articles, we\'ll explore some more of the features that can be added to these classes, but for now, let\'s focus on the basics.
The Model
is the base class for all models. Its job is to provide a way to define attributes and relationships. Model instances (or records) are, for the intents of this implementation, considered immutable. The RecordSet
is a collection of records, which can be queried and filtered.
In this article, I use the terms instance and record interchangeably. They both refer to a single model instance in memory. Do not confuse the term \\"record\\" with a database record, the JavaScript Record
type or the class of the same name implemented by various libraries.
As we\'ve already mentioned, all model records are immutable and loaded into memory. This means that each model\'s records are known ahead of time, so each model class can have a static
property to store all of its instances.
Unfortunately, if we are to create a core Model
class and define a static
property on it, it would be shared across all subclasses. This isn\'t what we want.
After rummaging through various websites, I came across static initialization blocks, a relatively new addition to JavaScript. It allows us to run some code when the class is defined, but before any instances are created.
\\nYou may be thinking but how does this help us? If we had a global storage for all model instances, we could use a static initialization block to create an array for each subclass. This way, each model class would have its own array of instances.
\\nBut how and where will we create such a storage? Well, in the Model
class, obviously! And, while we\'re at it, we can create a static method to prepare the storage for each model class. We\'ll later explore what other useful things we can bundle into this method in the future.
export default class Model {\\n static instances = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n }\\n}\\n
Having defined the Model
class and its prepare
method, we can now create a subclass of Model
and call the prepare
method on it inside a static initialization block. This will create an array for the subclass to store its instances.
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this);\\n }\\n}\\n
As you can see, we pass the model class itself to the prepare
method. This way, we can access the class name and create an array for it in the instances
object. After the new model is defined, the Model
class\'s instances
property will look like this:
Model.instaces; // { Post: [] }\\n
Querying records is the most crucial part of this implementation. After all, it\'s the main reason we\'re doing this. We can create a RecordSet
as a subclass of Array
class to handle this responsibility. It provides a way to query records based on their attributes.
While the name may suggest that the RecordSet
is a subclass of Set
, I\'ve found that arrays are much simpler to work with. Thus, I decided to subclass Array
instead, but the original name stuck, reminding me of its Set
origins.
Leveraging the power of array methods, we need only implement a handful of alias methods to make querying records a breeze. Fan favorites such as Array.prototype.filter()
, Array.prototype.find()
, Array.prototype.map()
, Array.prototype.reduce()
, and Array.prototype.sort()
are already available to us!
where
methodThe single most useful piece of functionality we need to implement is the where
method. This method will allow us to query records based on their attributes. Again, drawing inspiration from ActiveRecord, I chose to give it a great degree of flexibility, which is reflected in its arguments.
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n}\\n
This implementation allows us to query records based on their attributes. It expects a query object, where each key is an attribute name and each value is the expected value. The expectation can then vary quite a lot, namely:
\\n===
).We\'ll come back to use this method in just a moment, but first we need to populate the model instances!
\\nWe\'ve defined the basics of the core classes, but we also need to load some data into memory. The Model
is the best place to do this, via the use of its constructor
. This way, we can load the data into instances
when a new model instance is created.
export default class Model {\\n // ...\\n constructor() {\\n const modelName = this.constructor.name;\\n\\n // Store the instance in the instances array\\n Model.instances[modelName].push(this);\\n }\\n}\\n
Now, when we create a new instance of a model, it will be stored in the instances
array. Each class can simply call the super
constructor, then load its data into the instance.
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n // ...\\n constructor(data) {\\n super();\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n }\\n}\\n
Having set up the two core classes and loaded some data into memory, we should finally be able to query the records. Except, we don\'t have any way to query them yet.
\\nThis is yet another responsibility for the Model
class. Leveraging the resolution of this
in the context of static
methods, we can essentially create query methods on all subclasses, referencing themselves.
Ok, that last bit was somewhat confusing. Let me explain. If we define a static
method on Model
and it references this
, it will resolve to the subclass that called the method. Neat, right? This way, we can find the right set of instances in the instances
array.
Confused? Don\'t worry, the this
keyword is a tricky thing to master. I suggest reading this article to get a better understanding of it.
Given that a RecordSet
is just an array on steroids, we can create a new RecordSet
from it, using Array.from()
, or rather, RecordSet.from()
. Putting the pieces together, we can then define a static getter method, all
, on the Model
class, which will return a RecordSet
of all its instances.
import RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n // ...\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n}\\n
Why a getter method instead of a regular one? Well, it\'s a matter of preference. I like how parenthesis are optional in Ruby, so I wanted to mimic that behavior. This way, I can call Post.all
instead of Post.all()
. Feel free to tailor this to your own taste!
So, what can we do with this? Filter, sort, use where
and other methods on the RecordSet
! Let\'s see how we can use the where
method to query the records.
const posts = [\\n new Post({ id: 1, title: \'A post\', content: \'...\' }),\\n new Post({ id: 2, title: \'Other post\', content: \'...\' }),\\n new Post({ id: 3, title: \'A draft\', content: \'...\' }),\\n];\\n\\nPost.all.where({ title: \'First post\' });\\n// [Post { id: 1, title: \'First post\', content: \'...\' }]\\n\\nPost.all.where({ title: title => title.startsWith(\'A\') });\\n// [\\n// Post { id: 1, title: \'A post\', content: \'...\' },\\n// Post { id: 3, title: \'A draft\', content: \'...\' }\\n// ]\\n\\nPost.all.where({ id: [2, 3] });\\n// [\\n// Post { id: 1, title: \'A post\', content: \'...\' },\\n// Post { id: 2, title: \'Other post\', content: \'...\' }\\n// ]\\n\\nPost.all.where({ id: id => id % 2 === 1, title: \'A post\' });\\n// [Post { id: 1, title: \'A post\', content: \'...\' }]\\n
Querying is relatively simple using where
, but we can also use other array methods. For example, we can map over the records or even reduce them!
Post.all.map(post => post.title);\\n// [\'A post\', \'Other post\', \'A draft\']\\n\\nPost.all.reduce((acc, post) =>\\n post.title.includes(\'post\') ? acc + 1 : acc, 0);\\n// 2\\n
Before we wrap up, let\'s add some finishing touches, at least for the time being.
\\nFirst off, I want to make where
available to the models themselves. Calling all
seems a little to verbose, after all.
export default class Model {\\n // ...\\n static where(query) {\\n return this.all.where(query);\\n }\\n}\\n
See how we\'re using this
in the where
method again? Which will in turn call all
with the same this
context, ultimately resolving to the calling subclass. Eloquently simple, isn\'t it?
Apart from ActiveRecord, Ruby and Rails provide an absolute treasure trove of convenience methods. I especially like first
and last
on enumerable objects, making indexing in other languages seem so cumbersome. Let\'s add these to our RecordSet
as well.
export default class RecordSet extends Array {\\n // ...\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
Finally, I\'d like a way to quickly pull some attributes from the records or create a set of partial records. This is where we can leverage the array-like behavior of the RecordSet
and combine it with the power of Array.prototype.map()
.
A pluck
method will allow us to pull a single attribute from each record, while a select
method will allow us to pull multiple attributes from each record.
export default class RecordSet extends Array {\\n // ...\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n}\\n
If you\'re asking how are these different from Array.prototype.map()
, they\'re merely a convenience wrapper around it. We intentionally return a RecordSet
from these methods, so we can chain them with other RecordSet
methods. Remember RecordSet
instances are also arrays, so they can be used as such, too, which makes them very flexible.
One finer detail here is that we use super
to call the Array.prototype.map()
method. This is because we\'re extending Array
and we want to call the original method, in case we ever decide to override it in our class (spoiler: I\'ve done this in my original implementation).
I hope you\'ve enjoyed this deep dive into this particular implementation. While it may seem esoteric at first, it\'s a great opportunity to learn about more complex topics and modern JavaScript features. I\'ve found the implementation to be quite capable, and I\'ve already put it to good use. I\'m sure you can find some use for it, too!
\\nWow, that was a lot! And here I was thinking I\'d explain the rest of the codebase in this article. I guess I\'ll have to save that for another time, so stay tuned for that!
\\nIf you\'re looking for the complete implementation, you can find it below. More code will be added in subsequent articles, so it may come in handy as a reference point.
\\nimport RecordSet from \'#src/core/recordSet.js\';\\n\\nexport default class Model {\\n static instances = {};\\n\\n static prepare(model) {\\n const name = model.name;\\n\\n // Create an array for each model to store instances\\n if (!Model.instances[name]) Model.instances[name] = [];\\n }\\n\\n constructor(data) {\\n const modelName = this.constructor.name;\\n\\n // Store the instance in the instances array\\n Model.instances[modelName].push(this);\\n }\\n\\n static get all() {\\n return RecordSet.from(Model.instances[this.name] || []);\\n }\\n\\n static where(query) {\\n return this.all.where(query);\\n }\\n}\\n
export default class RecordSet extends Array {\\n where(query) {\\n return RecordSet.from(\\n this.filter(record => {\\n return Object.keys(query).every(key => {\\n // If function use it to determine matches\\n if (typeof query[key] === \'function\')\\n return query[key](record[key]);\\n\\n // If array, use it to determine matches\\n if (Array.isArray(query[key]))\\n return query[key].includes(record[key]);\\n\\n // If single value, use strict equality\\n return record[key] === query[key];\\n });\\n })\\n );\\n }\\n\\n pluck(attribute) {\\n return RecordSet.from(super.map(record => record[attribute]))\\n }\\n\\n select(...attributes) {\\n return RecordSet.from(super.map(record =>\\n attributes.reduce((acc, attribute) => {\\n acc[attribute] = record[attribute];\\n return acc;\\n }, {})\\n ));\\n }\\n\\n get first() {\\n return this[0];\\n }\\n\\n get last() {\\n return this[this.length - 1];\\n }\\n}\\n
import Model from \'#src/core/model.js\';\\n\\nexport default class Post extends Model {\\n static {\\n // Prepare storage for the Post model\\n super.prepare(this);\\n }\\n\\n constructor(data) {\\n super();\\n this.id = data.id;\\n this.title = data.title;\\n this.content = data.content;\\n }\\n}\\n
Last updated: December 5, 2024 View on GitHub ·\\nJoin the discussion
","description":"I\'ve been working with Ruby on Rails quite a lot lately. What I\'ve come to like about it the most may be its ActiveRecord ORM (Object-Relational Mapping). I won\'t go into detail about here, but I wanted to share a similar sort of concept I put together for this very website…","guid":"https://www.30secondsofcode.org/js/s/complex-object-collections-in-memory","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-12-04T16:00:00.677Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/digital-nomad-4-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/digital-nomad-4-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"How can I get all ancestors, parents, siblings, and children of an element?","url":"https://www.30secondsofcode.org/js/s/get-ancestors-parents-siblings-children","content":"DOM traversal is a very useful skill to have if you\'re working with JavaScript and the browser. It allows you to navigate the DOM tree and find elements that are related to a given element. Let\'s explore how to use it to our advantage.
\\nAll the examples in this article make use of the Node
interface, which is the base class for all nodes in the DOM, including elements, text nodes, and comments. Additionally, the functions return arrays of elements, not NodeList
objects, to make them easier to work with.
Oddly enough, there are two ways to get an element\'s children: Node.childNodes
and Node.children
. The difference between the two is that Node.childNodes
returns all child nodes, including text nodes, while Node.children
returns only element nodes.
Depending on our needs, we can use either of these properties to get an element\'s children, so let\'s use an argument to decide which one to return.
\\nconst getChildren = (el, includeTextNodes = false) =>\\n includeTextNodes ? [...el.childNodes] : [...el.children];\\n\\ngetChildren(document.querySelector(\'ul\'));\\n// [li, li, li]\\n\\ngetChildren(document.querySelector(\'ul\'), true);\\n// [li, #text, li, #text, li, #text]\\n
To get an element\'s siblings, we can use the Node.parentNode
property to access the parent node and then use Node.childNodes
to get all the children of the parent.
We can then convert the NodeList
to an array using the spread operator (...
). Finally, we can filter out the element itself from the list of children to get the siblings using Array.prototype.filter()
.
const getSiblings = el =>\\n [...el.parentNode.childNodes].filter(node => node !== el);\\n\\ngetSiblings(document.querySelector(\'head\'));\\n// [\'body\']\\n
To get all the ancestors of an element, we can use a while
loop and the Node.parentNode
property to move up the ancestor tree of the element. We can then use Array.prototype.unshift()
to add each new ancestor to the start of the array.
const getAncestors = el => {\\n let ancestors = [];\\n\\n while (el) {\\n ancestors.unshift(el);\\n el = el.parentNode;\\n }\\n\\n return ancestors;\\n};\\n\\ngetAncestors(document.querySelector(\'nav\'));\\n// [document, html, body, header, nav]\\n
Building on top of the previous examples, we can create functions to match related nodes based on a given condition. For example, we can find all the ancestors of an element up until the element matched by a specified selector, or find the closest anchor element to a given node.
\\nTo check if an element contains another element, we can simply use the Node.contains()
method.
const elementContains = (parent, child) =>\\n parent !== child && parent.contains(child);\\n\\nelementContains(\\n document.querySelector(\'head\'),\\n document.querySelector(\'title\')\\n);\\n// true\\n\\nelementContains(\\n document.querySelector(\'body\'),\\n document.querySelector(\'body\')\\n);\\n// false\\n
Finding the closest matching node starting at the given node
is often useful for event handling. We can use a for
loop and Node.parentNode
to traverse the node tree upwards from the given node
. We then use Element.matches()
to check if any given element node matches the provided selector
.
const findClosestMatchingNode = (node, selector) => {\\n for (let n = node; n.parentNode; n = n.parentNode)\\n if (n.matches && n.matches(selector)) return n;\\n\\n return null;\\n};\\n\\nfindClosestMatchingNode(\\n document.querySelector(\'a\'), \'body\'\\n);\\n// body\\n
To find all the ancestors of an element up until the element matched by the specified selector, we can use a while
loop and Node.parentNode
to move up the ancestor tree of the element. We can then use Array.prototype.unshift()
to add each new ancestor to the start of the array and Element.matches()
to check if the current element matches the specified selector
.
const getParentsUntil = (el, selector) => {\\n let parents = [], _el = el.parentNode;\\n\\n while (_el && typeof _el.matches === \'function\') {\\n parents.unshift(_el);\\n\\n if (_el.matches(selector)) return parents;\\n else _el = _el.parentNode;\\n }\\n\\n return [];\\n};\\n\\ngetParentsUntil(document.querySelector(\'#home-link\'), \'header\');\\n// [header, nav, ul, li]
Last updated: August 21, 2024 View on GitHub
","description":"DOM traversal is a very useful skill to have if you\'re working with JavaScript and the browser. It allows you to navigate the DOM tree and find elements that are related to a given element. Let\'s explore how to use it to our advantage.\\n\\n💬 Note\\n\\nAll the examples in this article…","guid":"https://www.30secondsofcode.org/js/s/get-ancestors-parents-siblings-children","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-08-20T16:00:00.599Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/flowering-hills-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/flowering-hills-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Browser "],"attachments":null,"extra":null,"language":null},{"title":"How can I group and count values in a JavaScript array?","url":"https://www.30secondsofcode.org/js/s/count-grouped-elements","content":"Finding the count of each value in an array can come in handy in a lot of situations. It\'s also fairly straightforward to implement, both for primitive and complex values, using JavaScript\'s Array
methods.
You can use Array.prototype.reduce()
to create an object with the unique values of an array as keys and their frequencies as the values. Use the nullish coalescing operator (??
) to initialize the value of each key to 0
if it doesn\'t exist and increment it by 1
every time the same value is encountered.
const frequencies = arr =>\\n arr.reduce((a, v) => {\\n a[v] = (a[v] ?? 0) + 1;\\n return a;\\n }, {});\\n\\nfrequencies([\'a\', \'b\', \'a\', \'c\', \'a\', \'a\', \'b\']);\\n// { a: 4, b: 2, c: 1 }\\nfrequencies([...\'ball\']);\\n// { b: 1, a: 1, l: 2 }\\n
You can also group the elements of an array based on a given function and return the count of elements in each group. This can be useful when you want to group elements based on a specific property or a function.
\\nTo do so, you can use Array.prototype.map()
to map the values of an array to a function or property name, and then use Array.prototype.reduce()
to create an object, where the keys are produced from the mapped results.
const countBy = (arr, fn) =>\\n arr\\n .map(typeof fn === \'function\' ? fn : val => val[fn])\\n .reduce((acc, val) => {\\n acc[val] = (acc[val] || 0) + 1;\\n return acc;\\n }, {});\\n\\ncountBy([6.1, 4.2, 6.3], Math.floor);\\n// {4: 1, 6: 2}\\ncountBy([\'one\', \'two\', \'three\'], \'length\');\\n// {3: 2, 5: 1}\\ncountBy([{ count: 5 }, { count: 10 }, { count: 5 }], x => x.count);\\n// {5: 2, 10: 1}\\n
Map
instead of an objectBoth of the previous examples use objects to store the frequencies of the values. However, you can also use a Map
to store the frequencies. This can be useful if you need to keep the insertion order of the keys or if you need to iterate over the keys in the order they were inserted. It\'s also more efficient when you need to delete keys or check for the existence of a key.
const frequenciesMap = arr =>\\n arr.reduce((a, v) => a.set(v, (a.get(v) ?? 0) + 1), new Map());\\n\\nfrequenciesMap([\'a\', \'b\', \'a\', \'c\', \'a\', \'a\', \'b\']);\\n// Map(3) { \'a\' => 4, \'b\' => 2, \'c\' => 1 }\\n\\nconst countByMap = (arr, fn) =>\\n arr\\n .map(typeof fn === \'function\' ? fn : val => val[fn])\\n .reduce((acc, val) => {\\n acc.set(val, (acc.get(val) || 0) + 1);\\n return acc;\\n }, new Map());\\n\\ncountByMap([6.1, 4.2, 6.3], Math.floor);\\n// Map(2) { 6 => 2, 4 => 1 }\\ncountByMap([\'one\', \'two\', \'three\'], \'length\');\\n// Map(2) { 3 => 2, 5 => 1 }\\ncountByMap([{ count: 5 }, { count: 10 }, { count: 5 }], x => x.count);\\n// Map(2) { 5 => 2, 10 => 1 }\\n
When dealing with data that changes often, you may need to recalculate frequencies as needed. This can become tedious and inefficient, especially if you only need to keep track of the frequencies and have no need for the original array.
\\nIn such cases, it might be preferable to create a custom data structure to store the data. This data structure will be able to keep track of the frequencies of the values it contains and update them as needed.
\\nMoreover, you may want to be able to check the frequency of any value, even if it doesn\'t exist in the data structure. This also comes in handy if you want to easily increment or decrement the frequency of a value. Let\'s take a look at how you can implement such a data structure:
\\nclass FrequencyMap extends Map {\\n constructor(iterable) {\\n super();\\n iterable.forEach(value => this.increment(value));\\n }\\n\\n get(value) {\\n return super.get(value) ?? 0;\\n }\\n\\n has(value) {\\n return super.get(value) > 0;\\n }\\n\\n increment(value) {\\n super.set(value, this.get(value) + 1);\\n return this;\\n }\\n\\n decrement(value) {\\n super.set(value, Math.max(this.get(value) - 1, 0));\\n return this;\\n }\\n\\n toSortedArray(ascending = true) {\\n if (ascending)\\n return [...this].sort((a, b) => a[1] - b[1]).map(v => v[0]);\\n else return [...this].sort((a, b) => b[1] - (1)[1]).map(v => v[0]);\\n }\\n}\\n\\nconst fMap = new FrequencyMap([\'a\', \'b\', \'c\', \'a\', \'a\', \'b\']);\\n\\nfMap.decrement(\'c\');\\nfMap.increment(\'d\');\\n\\nconsole.log(fMap.toSortedArray(false)); // [ \'a\', \'b\' , \'d\' ]\\n
Leveraging the built-in Map
class via the use of inheritance, we implement get()
in such a way that non-existent values will return 0
. Similarly, we implement has()
to check if the frequency of a value is greater than 0
. We also implement increment()
and decrement()
to increase or decrease the frequency of a value, respectively. Finally, we implement toSortedArray()
to return an array of the values sorted by their frequency.
As the data structure operates more like a Set
, the constructor
is allowed to accept an array of values. We then call the Map()
constructor without any arguments, and use Array.prototype.forEach()
to call the increment()
method for each value, populating the data structure.
Last updated: August 19, 2024 View on GitHub
","description":"Finding the count of each value in an array can come in handy in a lot of situations. It\'s also fairly straightforward to implement, both for primitive and complex values, using JavaScript\'s Array methods.\\n\\nCount the occurrences of each value in an array\\n\\nYou can use Array…","guid":"https://www.30secondsofcode.org/js/s/count-grouped-elements","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-08-18T16:00:00.150Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/tropical-waterfall-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/tropical-waterfall-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Array "],"attachments":null,"extra":null,"language":null},{"title":"Render DOM elements with JavaScript","url":"https://www.30secondsofcode.org/js/s/render-dom-element","content":"Have you ever wondered how React\'s rendering works under the hood? How about implementing a simple version of it in vanilla JavaScript yourself? Luckily, this is not as complicated as it might sound. Let\'s dive in!
\\nThis implementation is for demonstration purposes only and lacks many features and optimizations present in React or Preact. If you only need a simple way to render a DOM tree, you might want to look into a full-fledged package.
\\nIn order to render a DOM tree in a specified container, you can create a function that takes an object representing the DOM tree and the container element. This function will recursively create and append DOM elements based on the given tree. But before we get to that, we need to define how we\'ll represent the DOM tree.
\\nReact\'s virtual DOM representation is similar to the object structure we\'ll be using. Each element is an object with a type
property representing the element\'s tag name, and a props
object containing the element\'s attributes, event listeners, and children.
const myElement = {\\n type: \'button\',\\n props: {\\n type: \'button\',\\n className: \'btn\',\\n onClick: () => alert(\'Clicked\'),\\n children: [{ props: { nodeValue: \'Click me\' } }]\\n }\\n};\\n// Equivalent to:\\n// <button\\n// type=\\"button\\"\\n// class=\\"btn\\"\\n// onClick=\\"alert(\'Clicked\')\\"\\n// >\\n// Click me\\n// </button>\\n
The special case of text elements is represented by an object without a type
property, only containing a props
object with a nodeValue
property.
Additionally, some special prop rules apply to props named a certain way. For example, event listeners are prefixed with \'on\'
, and children are stored in an array under the \'children\'
key. As we\'re using the Element
conventions, other attributes might have different names than in HTML (e.g., className
instead of class
).
Having a robust representation of the DOM tree, we can now create the function that renders the given tree in the specified container. The function will destructure the first argument into type
and props
, and use type
to determine if the given element is a text element.
Based on the element\'s type
, it will create the DOM element using either Document.createTextNode()
or Document.createElement()
. The difference between text and element nodes is that text nodes don\'t have a type
property.
In order to process the rest of the props
, we\'ll need two helper functions: one to check if a property is an event listener and another to check if it\'s an attribute. Using simple heuristics, we can determine listeners by checking if the property starts with \'on\'
and attributes by checking if the property is not a listener or \'children\'
.
Having set up these rules, we can then iterate over props
, using Object.keys()
, to add attributes to the DOM element and set event listeners as necessary. If the element has children, we\'ll use recursion to render them. Finally, we\'ll append the DOM element to the specified container
using Node.appendChild()
.
const renderElement = ({ type, props = {} }, container) => {\\n const isTextElement = !type;\\n const element = isTextElement\\n ? document.createTextNode(\'\')\\n : document.createElement(type);\\n\\n const isListener = p => p.startsWith(\'on\');\\n const isAttribute = p => !isListener(p) && p !== \'children\';\\n\\n Object.keys(props).forEach(p => {\\n if (isAttribute(p)) element[p] = props[p];\\n if (!isTextElement && isListener(p))\\n element.addEventListener(p.toLowerCase().slice(2), props[p]);\\n });\\n\\n if (!isTextElement && props.children && props.children.length)\\n props.children.forEach(childElement =>\\n renderElement(childElement, element)\\n );\\n\\n container.appendChild(element);\\n};\\n\\nconst myElement = {\\n type: \'button\',\\n props: {\\n type: \'button\',\\n className: \'btn\',\\n onClick: () => alert(\'Clicked\'),\\n children: [{ props: { nodeValue: \'Click me\' } }]\\n }\\n};\\n\\nrenderElement(myElement, document.body);\\n// Renders our <button> element in the body of the document\\n
And that\'s it! You\'ve just created a simple function that renders a DOM tree in a specified container. This is a great exercise to understand how React\'s rendering works under the hood and to get a better grasp of how to manipulate the DOM with JavaScript. You can now experiment with different elements and attributes to see how they render in the browser, or extend the function with additional features to suit your needs. Enjoy!
Last updated: July 6, 2024 View on GitHub
","description":"Have you ever wondered how React\'s rendering works under the hood? How about implementing a simple version of it in vanilla JavaScript yourself? Luckily, this is not as complicated as it might sound. Let\'s dive in!\\n\\n❗️ Caution\\n\\nThis implementation is for demonstration purposes…","guid":"https://www.30secondsofcode.org/js/s/render-dom-element","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-07-05T16:00:00.612Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/standing-stones-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/standing-stones-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Browser "],"attachments":null,"extra":null,"language":null},{"title":"Convert between CSV and JavaScript arrays, objects or JSON","url":"https://www.30secondsofcode.org/js/s/convert-csv-to-array-object-or-json","content":"Comma-separated values (CSV) is a simple text format for storing tabular data. Each line in a CSV file represents a row, and each value in a row is separated by a delimiter, usually a comma. As CSV is used very often for exchanging data between different systems, it\'s important to know how to convert between JavaScript arrays or objects and CSV data.
\\nAs CSV format RFC 4180 is not standardized, there are many variations in how CSV data is formatted. This guide will cover the most common cases, but you may need to adjust the code to fit your specific use-case (see the table below for edge cases and how they are handled).
\\nSerializing data to CSV is generally straightforward. Using Array.prototype.join()
with a delimiter of your choice, you can easily create a single row from an array of values.
const serializeRow = (row, delimiter = \',\') => row.join(delimiter);\\n\\nserializeRow([\'a\', \'b\']);\\n// \'a,b\'\\nserializeRow([\'a\', \'b\'], \';\');\\n// \'a;b\'\\n
However, there are edge cases that need handling. These include empty values, values that contain the delimiter character, values that contain newlines, and values that contain double quotes. Here\'s a handy table of edge cases and how to handle them:
\\nCase | \\nSample value | \\nCSV representation | \\n
---|---|---|
Simple case | \\n[\'a\',\'b\'] | \\na,b | \\n
Empty value | \\n[\'a\',null] | \\na, | \\n
Value contains delimiter | \\n[\'a,b\',\'c\'] | \\n\\"a,b\\",c | \\n
Value contains newline | \\n[\'a\\\\nb\',\'c\'] | \\n\\"a\\\\\\\\nb\\",c | \\n
Value contains double quotes | \\n[\'a\\"b\',\'c\'] | \\n\\"a\\"\\"b\\",c | \\n
That being said, we can update our serializeRow
function to handle these edge cases. We should also extract the logic for serializing a single value to a separate function. Additionally, let\'s define a function that checks for empty values, so we can customize it as needed.
const isEmptyValue = value =>\\n value === null || value === undefined || Number.isNaN(value);\\n\\nconst serializeValue = (value, delimiter = \',\') => {\\n if (isEmptyValue(value)) return \'\';\\n value = `${value}`;\\n if (\\n value.includes(delimiter) ||\\n value.includes(\'\\\\n\') ||\\n value.includes(\'\\"\')\\n )\\n return `\\"${value.replace(/\\"/g, \'\\"\\"\').replace(/\\\\n/g, \'\\\\\\\\n\')}\\"`;\\n return value;\\n};\\n\\nconst serializeRow = (row, delimiter = \',\') =>\\n row.map(value => serializeValue(`value`, delimiter)).join(delimiter);\\n\\nserializeRow([\'a\', \'b\']);\\n// \'a,b\'\\nserializeRow([\'a\', null]);\\n// \'a,\'\\nserializeRow([\'a,b\', \'c\']);\\n// \'\\"a,b\\",c\'\\nserializeRow([\'a\\\\nb\', \'c\']);\\n// \'\\"a\\\\\\\\nb\\",c\'\\nserializeRow([\'a\\"b\', \'c\']);\\n// \'\\"a\\"\\"b\\",c\'\\n
Now that we have a way to serialize values and rows, we can move on to more complex use-cases, such as serializing a whole array of values or objects.
\\nSerializing an array of values into CSV is essentially just moving from one dimension to two. We can use Array.prototype.map()
to serialize each row and then join them together with newline characters (\\\\n
).
const arrayToCSV = (arr, delimiter = \',\') =>\\n arr.map(row => serializeRow(row, delimiter)).join(\'\\\\n\');\\n\\narrayToCSV([[\'a\', \'b\'], [\'c\', \'d\']]);\\n// \'a,b\\\\nc,d\'\\narrayToCSV([[\'a,b\', \'c\'], [\'d\', \'e\\\\n\\"f\\"\']]) ;\\n// \'\\"a,b\\",c\\\\nd,\\"e\\\\\\\\n\\"\\"f\\"\\"\\"\'\\n
Converting JavaScript objects to CSV is a bit more involved. As objects contain key-value pairs, we need to serialize the values in the correct order based on the keys. We can either use a predefined list of keys or extract them from the objects themselves. Additionally, we\'ll have to include the keys as the header (first row) of the CSV.
\\nIn short, extracting the header row from the objects involves collecting all unique keys from the objects. We can achieve this by using Array.prototype.reduce()
and a Set
to keep track of the unique keys.
const extractHeaders = (arr) =>\\n [...arr.reduce((acc, obj) => {\\n Object.keys(obj).forEach((key) => acc.add(key));\\n return acc;\\n }, new Set())];\\n\\nextractHeaders([\\n { a: 1, b: 2 },\\n { a: 3, b: 4, c: 5 },\\n { a: 6 },\\n { b: 7 },\\n]);\\n// [\'a\', \'b\', \'c\']\\n
Now that we have the headers, we can use them to serialize the objects into CSV. In order to allow for more flexibility, we\'ll pass the headers as an argument to the function. This way, we can omit headers or include only specific ones. The default value for the argument should be the result of extractHeaders
. Moreover, we may want to omit the header row altogether, so we\'ll add an optional argument for that as well.
As a rule of thumb, the objects need to be JSON-serializable, or at least the subset of keys we\'re interested in.
\\nconst objectToCSV = (\\n arr,\\n headers = extractHeaders(arr),\\n omitHeaders = false,\\n delimiter = \',\'\\n) => {\\n const headerRow = serializeRow(headers, delimiter);\\n const bodyRows = arr.map(obj =>\\n serializeRow(\\n headers.map(key => obj[key]),\\n delimiter\\n )\\n );\\n return omitHeaders\\n ? bodyRows.join(\'\\\\n\')\\n : [headerRow, ...bodyRows].join(\'\\\\n\');\\n};\\n\\nobjectToCSV(\\n [{ a: 1, b: 2 }, { a: 3, b: 4, c: 5 }, { a: 6 }, { b: 7 }]\\n);\\n// \'a,b,c\\\\n1,2,\\\\n3,4,5\\\\n6,,\\\\n,7,\'\\nobjectToCSV(\\n [{ a: 1, b: 2 }, { a: 3, b: 4, c: 5 }, { a: 6 }, { b: 7 }],\\n [\'a\', \'b\']\\n);\\n// \'a,b\\\\n1,2\\\\n3,4\\\\n6,\\\\n,7\'\\nobjectToCSV(\\n [{ a: 1, b: 2 }, { a: 3, b: 4, c: 5 }, { a: 6 }, { b: 7 }],\\n [\'a\', \'b\'],\\n true\\n);\\n// \'1,2\\\\n3,4\\\\n6,\\\\n,7\'\\n
Converting JSON data to CSV is a simple extension of the previous functions. We can parse the JSON data into an array of objects and then use the objectToCSV
function to serialize it.
const JSONToCSV = (json, headers, omitHeaders) =>\\n objectToCSV(JSON.parse(json), headers, omitHeaders);\\n\\nJSONToCSV(\\n \'[{\\"a\\":1,\\"b\\":2},{\\"a\\":3,\\"b\\":4,\\"c\\":5},{\\"a\\":6},{\\"b\\":7}]\'\\n);\\n// \'a,b,c\\\\n1,2,\\\\n3,4,5\\\\n6,,\\\\n,7,\'\\nJSONToCSV(\\n \'[{\\"a\\":1,\\"b\\":2},{\\"a\\":3,\\"b\\":4,\\"c\\":5},{\\"a\\":6},{\\"b\\":7}]\',\\n [\'a\', \'b\']\\n);\\n// \'a,b\\\\n1,2\\\\n3,4\\\\n6,\\\\n,7\'\\nJSONToCSV(\\n \'[{\\"a\\":1,\\"b\\":2},{\\"a\\":3,\\"b\\":4,\\"c\\":5},{\\"a\\":6},{\\"b\\":7}]\',\\n [\'a\', \'b\'],\\n true\\n);\\n// \'1,2\\\\n3,4\\\\n6,\\\\n,7\'\\n
Parsing CSV data back into arrays or objects can be trickier than serialization. The same edge cases need to be handled when deserializing the data. Let\'s focus on row deserialization first. While we can use regular expressions for a more elegant solution, we\'ll stick to a simple while
loop for this implementation for readability.
Starting from the beginning of the row, we\'ll iterate over each character and build up the values. We will keep track if we\'re inside quotations, while also skipping two subsequent quotes if they\'re part of an escaped quote. When we encounter the delimiter character and we\'re not inside quotations, we\'ll push the value to the array and move on to the next one. We\'ll also use String.prototype.replace()
to handle escaped quotes and newlines and to trim enclosing quotes from the values.
const deserializeRow = (row, delimiter = \',\') => {\\n const values = [];\\n let index = 0, matchStart = 0, isInsideQuotations = false;\\n while (true) {\\n if (index === row.length) {\\n values.push(row.slice(matchStart, index));\\n break;\\n }\\n const char = row[index];\\n if (char === delimiter && !isInsideQuotations) {\\n values.push(\\n row\\n .slice(matchStart, index)\\n .replace(/^\\"|\\"$/g, \'\')\\n .replace(/\\"\\"/g, \'\\"\')\\n .replace(/\\\\\\\\n/g, \'\\\\n\')\\n );\\n matchStart = index + 1;\\n }\\n if (char === \'\\"\')\\n if (row[index + 1] === \'\\"\') index += 1;\\n else isInsideQuotations = !isInsideQuotations;\\n index += 1;\\n }\\n return values;\\n};\\n\\ndeserializeRow(\'a,b\');\\n// [\'a\', \'b\']\\ndeserializeRow(\'a,\');\\n// [\'a\', \'\']\\ndeserializeRow(\'\\"a,b\\",c\');\\n// [\'a,b\', \'c\']\\ndeserializeRow(\'\\"a\\"\\"b\\",c\');\\n// [\'a\\"b\', \'c\']\\ndeserializeRow(\'\\"a\\\\\\\\nb\\",c\');\\n// [\'a\\\\nb\', \'c\']\\n
Having a function to deserialize a single row, we can now move on to deserializing the whole CSV data. We\'ll split the data into rows and use the deserializeRow
function to extract the values.
All values are strings after deserialization. To convert them back to their original types, you\'ll have to do so manually. As this will depend on your specific use-case, it will not be covered here, however you can easily add an optional argument to parse values as needed.
\\nconst deserializeCSV = (data, delimiter = \',\') =>\\n data.split(\'\\\\n\').map(row => deserializeRow(row, delimiter));\\n\\ndeserializeCSV(\'a,b\\\\nc,d\');\\n// [[\'a\', \'b\'], [\'c\', \'d\']]\\ndeserializeCSV(\'a;b\\\\nc;d\', \';\');\\n// [[\'a\', \'b\'], [\'c\', \'d\']]\\ndeserializeCSV(\'\\"a,b\\",c\\\\n\\"a\\"\\"b\\",c\');\\n// [[\'a,b\', \'c\'], [\'a\\"b\', \'c\']]\\ndeserializeCSV(\'a,\\\\n\\"a\\\\\\\\nb\\",c\');\\n// [[\'a\', \'\'], [\'a\\\\nb\', \'c\']]\\n
The previous function is a good starting point for deserializing CSV data into arrays. However, we might want to omit the header row. We can add an optional argument to the function to handle this.
\\nconst CSVToArray = (data, delimiter = \',\', omitHeader = false) => {\\n const rows = data.split(\'\\\\n\');\\n if (omitHeader) rows.shift();\\n return rows.map(row => deserializeRow(row, delimiter));\\n};\\n\\nCSVToArray(\'a,b\\\\nc,d\');\\n// [[\'a\', \'b\'], [\'c\', \'d\']]\\nCSVToArray(\'a;b\\\\nc;d\', \';\');\\n// [[\'a\', \'b\'], [\'c\', \'d\']]\\nCSVToArray(\'col1,col2\\\\na,b\\\\nc,d\', \',\', true);\\n// [[\'a\', \'b\'], [\'c\', \'d\']]\\n
Converting CSV data into objects needs a couple of extra steps. We need to extract the header row to use as keys for the objects. We can then use these keys to create objects for each row. We\'ll use the same deserializeRow
function to extract the values for each row.
const CSVtoObject = (data, delimiter = \',\') => {\\n const rows = data.split(\'\\\\n\');\\n const headers = deserializeRow(rows.shift(), delimiter);\\n return rows.map((row) => {\\n const values = deserializeRow(row, delimiter);\\n return headers.reduce((obj, key, index) => {\\n obj[key] = values[index];\\n return obj;\\n }, {});\\n });\\n};\\n\\nCSVtoObject(\'col1,col2\\\\na,b\\\\nc,d\');\\n// [{\'col1\': \'a\', \'col2\': \'b\'}, {\'col1\': \'c\', \'col2\': \'d\'}]\\nCSVtoObject(\'col1;col2\\\\na;b\\\\nc;d\', \';\');\\n// [{\'col1\': \'a\', \'col2\': \'b\'}, {\'col1\': \'c\', \'col2\': \'d\'}]\\n
Converting CSV data into JSON is a simple extension of the previous functions. We can use the CSVtoObject
function to parse the CSV data into objects and then use JSON.stringify
to serialize it.
const CSVToJSON = (data, delimiter = \',\') =>\\n JSON.stringify(CSVtoObject(data, delimiter));\\n\\nCSVToJSON(\'col1,col2\\\\na,b\\\\nc,d\');\\n// \'[{\\"col1\\":\\"a\\",\\"col2\\":\\"b\\"},{\\"col1\\":\\"c\\",\\"col2\\":\\"d\\"}]\'\\nCSVToJSON(\'col1;col2\\\\na;b\\\\nc;d\', \';\');\\n// \'[{\\"col1\\":\\"a\\",\\"col2\\":\\"b\\"},{\\"col1\\":\\"c\\",\\"col2\\":\\"d\\"}]\'\\n
This guide covered most of the groundwork necessary to convert between CSV data and JavaScript arrays, objects, or JSON. The provided functions can be customized or extended, depending on your specific needs. Remember that CSV is not a standardized format, so you may need to adjust the code to fit your specific use-case.
\\nconst isEmptyValue = value =>\\n value === null || value === undefined || Number.isNaN(value);\\n\\nconst serializeValue = (value, delimiter = \',\') => {\\n if (isEmptyValue(value)) return \'\';\\n value = `${value}`;\\n if (\\n value.includes(delimiter) ||\\n value.includes(\'\\\\n\') ||\\n value.includes(\'\\"\')\\n )\\n return `\\"${value.replace(/\\"/g, \'\\"\\"\').replace(/\\\\n/g, \'\\\\\\\\n\')}\\"`;\\n return value;\\n};\\n\\nconst serializeRow = (row, delimiter = \',\') =>\\n row.map(value => serializeValue(`value`, delimiter)).join(delimiter);\\n\\nconst extractHeaders = (arr) =>\\n [...arr.reduce((acc, obj) => {\\n Object.keys(obj).forEach((key) => acc.add(key));\\n return acc;\\n }, new Set())];\\n\\nconst arrayToCSV = (arr, delimiter = \',\') =>\\n arr.map(row => serializeRow(row, delimiter)).join(\'\\\\n\');\\n\\nconst objectToCSV = (\\n arr,\\n headers = extractHeaders(arr),\\n omitHeaders = false,\\n delimiter = \',\'\\n) => {\\n const headerRow = serializeRow(headers, delimiter);\\n const bodyRows = arr.map(obj =>\\n serializeRow(\\n headers.map(key => obj[key]),\\n delimiter\\n )\\n );\\n return omitHeaders\\n ? bodyRows.join(\'\\\\n\')\\n : [headerRow, ...bodyRows].join(\'\\\\n\');\\n};\\n\\nconst JSONToCSV = (json, headers, omitHeaders) =>\\n objectToCSV(JSON.parse(json), headers, omitHeaders);\\n
const deserializeRow = (row, delimiter = \',\') => {\\n const values = [];\\n let index = 0, matchStart = 0, isInsideQuotations = false;\\n while (true) {\\n if (index === row.length) {\\n values.push(row.slice(matchStart, index));\\n break;\\n }\\n const char = row[index];\\n if (char === delimiter && !isInsideQuotations) {\\n values.push(\\n row\\n .slice(matchStart, index)\\n .replace(/^\\"|\\"$/g, \'\')\\n .replace(/\\"\\"/g, \'\\"\')\\n .replace(/\\\\\\\\n/g, \'\\\\n\')\\n );\\n matchStart = index + 1;\\n }\\n if (char === \'\\"\')\\n if (row[index + 1] === \'\\"\') index += 1;\\n else isInsideQuotations = !isInsideQuotations;\\n index += 1;\\n }\\n return values;\\n};\\n\\nconst deserializeCSV = (data, delimiter = \',\') =>\\n data.split(\'\\\\n\').map(row => deserializeRow(row, delimiter));\\n\\nconst CSVToArray = (data, delimiter = \',\', omitHeader = false) => {\\n const rows = data.split(\'\\\\n\');\\n if (omitHeader) rows.shift();\\n return rows.map(row => deserializeRow(row, delimiter));\\n};\\n\\nconst CSVtoObject = (data, delimiter = \',\') => {\\n const rows = data.split(\'\\\\n\');\\n const headers = deserializeRow(rows.shift(), delimiter);\\n return rows.map((row) => {\\n const values = deserializeRow(row, delimiter);\\n return headers.reduce((obj, key, index) => {\\n obj[key] = values[index];\\n return obj;\\n }, {});\\n });\\n};\\n\\nconst CSVToJSON = (data, delimiter = \',\') =>\\n JSON.stringify(CSVtoObject(data, delimiter));\\n
Last updated: June 8, 2024 View on GitHub
","description":"Comma-separated values (CSV) is a simple text format for storing tabular data. Each line in a CSV file represents a row, and each value in a row is separated by a delimiter, usually a comma. As CSV is used very often for exchanging data between different systems, it\'s important…","guid":"https://www.30secondsofcode.org/js/s/convert-csv-to-array-object-or-json","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-06-07T16:00:00.029Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/tropical-bike-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/tropical-bike-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Array "],"attachments":null,"extra":null,"language":null},{"title":"Modeling money, currencies & exchange rates using JavaScript","url":"https://www.30secondsofcode.org/js/s/modeling-money-currency-exchange-rates","content":"Working with money, currencies and exchange rates is pretty common, yet it\'s no easy task no matter the language. While JavaScript doesn\'t have a lot of useful features built into it, Intl
can give us a head start with some parts of the process.
The first step in modeling any sort of monetary value is to have a structure for currency. Luckily, Intl
has this problem solved for us. You can use Intl.NumberFormat
with style: \'currency\'
to get a formatter for a specific currency. This formatter can then be used to format a number into a currency string.
We\'ve previously covered formatting a number into a currency string, which might cover some simpler use cases.
\\nIn order to retrieve all supported currencies, we can use Intl.supportedValuesOf()
with \'currency\'
as the argument. This will return an array of the ISO 4217 currency codes supported by the environment. Then, using Map()
, Array.prototype.map()
and Intl.NumberFormat
, we can create an object for all currencies, that will format values on demand.
const isoCodes = Intl.supportedValuesOf(\'currency\');\\n\\nconst currencyFields = [\'symbol\', \'narrowSymbol\', \'name\'];\\n\\nconst allCurrencies = new Map(\\n isoCodes.map(code => {\\n const format = currencyDisplay => value =>\\n Intl.NumberFormat(undefined, {\\n style: \'currency\',\\n currency: code,\\n currencyDisplay,\\n })\\n .format(value)\\n .trim();\\n return [code, Object.freeze({ code, format })];\\n })\\n);\\n// Returns a Map object with all currency information\\n// {\\n// \'USD\': { code: \'USD\', format: [Function] },\\n// \'EUR\': { code: \'EUR\', format: [Function] },\\n// ...\\n// }\\n
Notice how Object.freeze()
is used to prevent the object from being modified. This is important because we don\'t want to accidentally change the currency information.
Having set up all the currency information, we need a way to retrieve it when we need it. Getting the same currency object for the same currency code will be important later down the line for comparisons. As we have a Map
object, we can use Map.prototype.get()
to retrieve the currency object. As a safeguard, we should ensure the currency code matches the key, using String.prototype.toUpperCase()
.
const getCurrencyFromCode = code => {\\n const isoCode = code.toUpperCase();\\n return allCurrencies.get(isoCode);\\n};\\n\\nconst currency = getCurrencyFromCode(\'usd\');\\ncurrency.format(\'symbol\')(1000); // \'$1,000.00\'\\n
We can then create a simple static class to handle currency retrieval. Apart from a simple get()
method, we can also add a wrap()
method. This method will return the same currency object if it\'s passed as an argument. Otherwise, it will retrieve the currency object using the get()
method.
class Currency {\\n static get(code) {\\n const currency = getCurrencyFromCode(code);\\n if (!currency)\\n throw new RangeError(`Invalid currency ISO code \\"${code}\\"`);\\n return currency;\\n }\\n\\n static wrap(currency) {\\n if (\\n typeof currency === \'object\' &&\\n getCurrencyFromCode(currency.code) === currency\\n )\\n return currency;\\n\\n return Currency.get(currency);\\n }\\n}\\n\\nconst usd = Currency.get(\'usd\');\\nusd.format(\'symbol\')(1000); // \'$1,000.00\'\\n\\nconst usd2 = Currency.wrap(usd);\\nusd === usd2; // true\\n
A monetary value is simply a data structure that contains a value and a currency. Implementing a class for that is fairly simple, using the Currency
class from before. We can then use the currency object to format the value as needed.
class Money {\\n value;\\n currency;\\n\\n constructor(value, currency) {\\n this.value = Number.parseFloat(value);\\n this.currency = Currency.wrap(currency);\\n }\\n\\n format(currencyDisplay = \'symbol\') {\\n return this.currency.format(currencyDisplay)(this.value);\\n }\\n}\\n\\nconst money = new Money(1000, \'usd\');\\nmoney.format(); // \'$1,000.00\'\\nmoney.format(\'code\'); // \'USD 1,000.00\'\\n
This Money
implementation is fairly naive, as the values are stored as floating point numbers. This can lead to precision errors, especially when performing mathematical operations. You might want to consider more robust numeric representations, if you\'re using this in a production environment.
Performing mathematical operations with money is a bit more complex. We need to ensure that the currency is the same for both operands. We\'ll later cover how to handle exchange rates, but for now, we\'ll focus on the basic operations.
\\nOddly enough, multiplication and division are the low hanging fruits, as you can only multiply or divide by a scalar value. Similarly, the modulo and quotient operations can be implemented without any additional complexity.
\\nclass Money {\\n // ...\\n\\n multiply(num) {\\n return new Money(this.value * num, this.currency);\\n }\\n\\n divide(num) {\\n return new Money(this.value / num, this.currency);\\n }\\n\\n div(num) {\\n return new Money(Math.floor(this.value / num), this.currency);\\n }\\n\\n mod(num) {\\n return new Money(this.value % num, this.currency);\\n }\\n\\n divmod(num) {\\n return [this.div(num), this.mod(num)];\\n }\\n}\\n\\nconst money = new Money(1000, \'usd\');\\nmoney.multiply(2).format(); // \'$2,000.00\'\\nmoney.divide(2).format(); // \'$500.00\'\\nmoney.div(3).format(); // \'$333.00\'\\nmoney.mod(3).format(); // \'$1.00\'\\n
Addition and subtraction are the tough ones. We need to ensure that the currency is the same for both operands, which can be done by comparing the currency codes. If it\'s not, we can throw an error for the time being.
\\nclass Money {\\n // ...\\n\\n add(money) {\\n if (this.currency !== money.currency)\\n throw new Error(\'Cannot add money with different currencies\');\\n return new Money(this.value + money.value, this.currency);\\n }\\n\\n subtract(money) {\\n if (this.currency !== money.currency)\\n throw new Error(\'Cannot subtract money with different currencies\');\\n return new Money(this.value - money.value, this.currency);\\n }\\n}\\n\\nconst money1 = new Money(1000, \'usd\');\\nconst money2 = new Money(500, \'usd\');\\nmoney1.add(money2).format(); // \'$1,500.00\'\\nmoney1.subtract(money2).format(); // \'$500.00\'\\n
Finally, equality and comparison operations can be implemented by comparing the values and the currency codes. Again, we\'ll have to deal with the currency codes being different, but we\'ll throw an error for now.
\\nclass Money {\\n // ...\\n\\n equals(money) {\\n if (this.currency !== money.currency)\\n throw new Error(\'Cannot compare money with different currencies\');\\n return this.value === money.value;\\n }\\n\\n greaterThan(money) {\\n if (this.currency !== money.currency)\\n throw new Error(\'Cannot compare money with different currencies\');\\n return this.value > money.value;\\n }\\n\\n lessThan(money) {\\n if (this.currency !== money.currency)\\n throw new Error(\'Cannot compare money with different currencies\');\\n return this.value < money.value;\\n }\\n}\\n\\nconst money1 = new Money(1000, \'usd\');\\nconst money2 = new Money(500, \'usd\');\\nmoney1.equals(money2); // false\\nmoney1.greaterThan(money2); // true\\nmoney1.lessThan(money2); // false\\n
An exchange rate is simply a ratio between two currencies. Instead of modeling it as a class, using a wrapper object that contains multiple exchange rates provides more utility. We\'ll be calling this object a Bank
.
The Bank
class will contain a Map
object that maps currency pairs to exchange rates. We can add exchange rates using Map.prototype.set()
and retrieve them using Map.prototype.get()
. In order to keep things neat and ensure we can pass either currencies or ISO codes, we can use the Currency.wrap()
method from before.
class Bank {\\n exchangeRates;\\n\\n constructor() {\\n this.exchangeRates = new Map();\\n }\\n\\n setRate(from, to, rate) {\\n const fromCurrency = Currency.wrap(from);\\n const toCurrency = Currency.wrap(to);\\n const exchangeRate = Number.parseFloat(rate);\\n\\n this.exchangeRates.set(\\n `${fromCurrency.code} -> ${toCurrency.code}`,\\n exchangeRate\\n );\\n\\n return this;\\n }\\n\\n getRate(from, to) {\\n const fromCurrency = Currency.wrap(from);\\n const toCurrency = Currency.wrap(to);\\n\\n return this.exchangeRates.get(\\n `${fromCurrency.code} -> ${toCurrency.code}`\\n );\\n }\\n}\\n\\nconst bank = new Bank();\\nbank.setRate(\'usd\', \'eur\', 0.85);\\nbank.getRate(\'usd\', \'eur\'); // 0.85\\n
Converting money from one currency to another is a matter of multiplying the value by the exchange rate. The responsibility for exchanging money is part of the Bank
class, as it\'s the one that holds the exchange rates.
class Bank {\\n // ...\\n\\n exchange(money, to) {\\n if (!(money instanceof Money))\\n throw new TypeError(`${money} is not an instance of Money`);\\n\\n const toCurrency = Currency.wrap(to);\\n if (toCurrency === money.currency) return money;\\n\\n const exchangeRate = this.getRate(money.currency, toCurrency);\\n if (!exchangeRate)\\n throw new TypeError(\\n `No exchange rate found for ${money.currency.code} to ${toCurrency.code}`\\n );\\n return new Money(money.value * exchangeRate, toCurrency);\\n }\\n}\\n\\nconst bank = new Bank();\\nbank.setRate(\'usd\', \'eur\', 0.85);\\nconst money = new Money(1000, \'usd\');\\nbank.exchange(money, \'eur\').format(); // \'€850.00\'\\n
Using the Bank
class to exchange money works, but it\'s a lot of work to do every time. The more practical scenario would be to reference a Bank
instance from each Money
object and use it to exchange money.
class Money {\\n value;\\n currency;\\n bank;\\n\\n constructor(value, currency, bank) {\\n this.value = Number.parseFloat(value);\\n this.currency = Currency.wrap(currency);\\n if (!(bank instanceof Bank))\\n throw new TypeError(`${bank} is not an instance of Bank`);\\n this.bank = bank;\\n }\\n\\n // ...\\n\\n exchangeTo(currency) {\\n return this.bank.exchange(this, currency);\\n }\\n}\\n\\nconst bank = new Bank();\\nbank.setRate(\'usd\', \'eur\', 0.85);\\nconst money = new Money(1000, \'usd\', bank);\\nmoney.exchangeTo(\'eur\').format(); // \'€850.00\'\\n
However, passing the Bank
instance every time we create a Money
object is not practical. In most scenarios, you\'ll only ever have a single instance, which can be easily added to the class as a static property. This allows for Money
instances to default to the same instance, making exchanges easier.
class Bank {\\n static defaultBank;\\n\\n // ...\\n}\\n\\nclass Money {\\n // ...\\n\\n constructor(value, currency, bank = Bank.defaultBank) {\\n this.value = Number.parseFloat(value);\\n this.currency = Currency.wrap(currency);\\n if (!(bank instanceof Bank))\\n throw new TypeError(`${bank} is not an instance of Bank`);\\n this.bank = bank;\\n }\\n\\n // ...\\n}\\n\\nconst bank = new Bank();\\nbank.setRate(\'usd\', \'eur\', 0.85);\\nBank.defaultBank = bank;\\n\\nconst money = new Money(1000, \'usd\');\\nmoney.exchangeTo(\'eur\').format(); // \'€850.00\'\\n
If you plan on implementing historical exchange rates, you can use multiple Bank
instances, one for each date. This way, you can easily switch between them when needed.
Having set up exchange rates, we can now perform mathematical operations with money in different currencies. We need only check if two Money
objects are in the same currency before performing the operation. If they\'re not, we can exchange one of them to the other currency.
class Money {\\n // ...\\n\\n add(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return new Money(this.value + money.value, this.currency);\\n }\\n\\n subtract(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return new Money(this.value - money.value, this.currency);\\n }\\n\\n equals(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return this.value === money.value;\\n }\\n\\n greaterThan(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return this.value > money.value;\\n }\\n\\n lessThan(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return this.value < money.value;\\n }\\n\\n // ...\\n}\\n\\nconst bank = new Bank();\\nbank.setRate(\'usd\', \'eur\', 0.85);\\nbank.setRate(\'eur\', \'usd\', 1.18);\\nBank.defaultBank = bank;\\n\\nconst money1 = new Money(1000, \'usd\');\\nconst money2 = new Money(500, \'eur\');\\nmoney1.add(money2).format(); // \'$1,590.00\'\\nmoney1.subtract(money2).format(); // \'$410.00\'\\nmoney1.equals(money2); // false\\nmoney1.greaterThan(money2); // true\\nmoney1.lessThan(money2); // false\\n
Implementing a basic structure for money, currencies and exchange rates is a lot of work, but it\'s fairly straightforward once you get the basics down. There\'s plenty of improvements that you can make to this implementation, such as adding more mathematical operations, or handling historical exchange rates. However, this should give you a good starting point for any project that requires handling money.
\\nconst isoCodes = Intl.supportedValuesOf(\'currency\');\\n\\nconst currencyFields = [\'symbol\', \'narrowSymbol\', \'name\'];\\n\\nconst allCurrencies = new Map(\\n isoCodes.map(code => {\\n const format = currencyDisplay => value =>\\n Intl.NumberFormat(undefined, {\\n style: \'currency\',\\n currency: code,\\n currencyDisplay,\\n })\\n .format(value)\\n .trim();\\n return [code, Object.freeze({ code, format })];\\n })\\n);\\n\\nconst getCurrencyFromCode = code => {\\n const isoCode = code.toUpperCase();\\n return allCurrencies.get(isoCode);\\n};\\n\\nclass Currency {\\n static get(code) {\\n const currency = getCurrencyFromCode(code);\\n if (!currency)\\n throw new RangeError(`Invalid currency ISO code \\"${code}\\"`);\\n return currency;\\n }\\n\\n static wrap(currency) {\\n if (\\n typeof currency === \'object\' &&\\n getCurrencyFromCode(currency.code) === currency\\n )\\n return currency;\\n\\n return Currency.get(currency);\\n }\\n}\\n
class Bank {\\n static defaultBank;\\n\\n exchangeRates;\\n\\n constructor() {\\n this.exchangeRates = new Map();\\n }\\n\\n setRate(from, to, rate) {\\n const fromCurrency = Currency.wrap(from);\\n const toCurrency = Currency.wrap(to);\\n const exchangeRate = Number.parseFloat(rate);\\n\\n this.exchangeRates.set(\\n `${fromCurrency.code} -> ${toCurrency.code}`,\\n exchangeRate\\n );\\n\\n return this;\\n }\\n\\n getRate(from, to) {\\n const fromCurrency = Currency.wrap(from);\\n const toCurrency = Currency.wrap(to);\\n\\n return this.exchangeRates.get(\\n `${fromCurrency.code} -> ${toCurrency.code}`\\n );\\n }\\n\\n exchange(money, to) {\\n if (!(money instanceof Money))\\n throw new TypeError(`${money} is not an instance of Money`);\\n\\n const toCurrency = Currency.wrap(to);\\n if (toCurrency === money.currency) return money;\\n\\n const exchangeRate = this.getRate(money.currency, toCurrency);\\n if (!exchangeRate)\\n throw new TypeError(\\n `No exchange rate found for ${money.currency.code} to ${toCurrency.code}`\\n );\\n return new Money(money.value * exchangeRate, toCurrency);\\n }\\n}\\n
class Money {\\n value;\\n currency;\\n bank;\\n\\n constructor(value, currency, bank = Bank.defaultBank) {\\n this.value = Number.parseFloat(value);\\n this.currency = Currency.wrap(currency);\\n if (!(bank instanceof Bank))\\n throw new TypeError(`${bank} is not an instance of Bank`);\\n this.bank = bank;\\n }\\n\\n format(currencyDisplay = \'symbol\') {\\n return this.currency.format(currencyDisplay)(this.value);\\n }\\n\\n exchangeTo(currency) {\\n return this.bank.exchange(this, currency);\\n }\\n\\n add(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return new Money(this.value + money.value, this.currency);\\n }\\n\\n subtract(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return new Money(this.value - money.value, this.currency);\\n }\\n\\n multiply(num) {\\n return new Money(this.value * num, this.currency);\\n }\\n\\n divide(num) {\\n return new Money(this.value / num, this.currency);\\n }\\n\\n div(num) {\\n return new Money(Math.floor(this.value / num), this.currency);\\n }\\n\\n mod(num) {\\n return new Money(this.value % num, this.currency);\\n }\\n\\n divmod(num) {\\n return [this.div(num), this.mod(num)];\\n }\\n\\n equals(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return this.value === money.value;\\n }\\n\\n greaterThan(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return this.value > money.value;\\n }\\n\\n lessThan(money) {\\n if (this.currency !== money.currency)\\n money = money.exchangeTo(this.currency);\\n return this.value < money.value;\\n }\\n}\\n
Last updated: April 24, 2024 View on GitHub
","description":"Working with money, currencies and exchange rates is pretty common, yet it\'s no easy task no matter the language. While JavaScript doesn\'t have a lot of useful features built into it, Intl can give us a head start with some parts of the process.\\n\\nModeling currency\\n\\nThe first step…","guid":"https://www.30secondsofcode.org/js/s/modeling-money-currency-exchange-rates","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-04-23T16:00:00.699Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/money-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/money-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Math "],"attachments":null,"extra":null,"language":null},{"title":"Get a nested object property by key or path in JavaScript","url":"https://www.30secondsofcode.org/js/s/get-nested-object-value","content":"Working with objects, you\'ll often need to retrieve nested properties. It\'s not uncommon to have deeply nested objects and keys that are calculated dynamically and not known in advance. This means that you\'ll have to dynamically find the value of a nested property based on a key or a path string, or search for a property in an object.
\\nThe simplest scenario and by far the most common is having an array of keys that represent the path to the desired property in the object. In that case, all you need to do is use Array.prototype.reduce()
to iterate over the keys and get the nested property. If the key doesn\'t exist, you can return null
.
In order to keep the syntax concise, you can use the nullish coalescing operator (??
) and the optional chaining operator (?.
) to handle cases where the property doesn\'t exist.
const deepGet = (obj, keys) => keys.reduce((xs, x) => xs?.[x] ?? null, obj);\\n\\nconst data = {\\n foo: {\\n foz: [1, 2, 3],\\n bar: { baz: [\'a\', \'b\', \'c\'] },\\n },\\n};\\n\\ndeepGet(data, [\'foo\', \'foz\', 2]); // 3\\ndeepGet(data, [\'foo\', \'bar\', \'baz\', 8, \'foz\']); // null\\n
A less common, yet more complex use-case is when you need to get a nested object property based on a path string. This is useful when you have a string that represents the path to the desired property, like \'foo.bar.baz\'
.
In that case, you will have to normalize the path string and split it into an array of keys. The normalization process involves using String.prototype.replace()
to replace square brackets with dots, and then splitting the string via String.prototype.split()
. As this might produce empty strings, you should filter them out, using Array.prototype.filter()
.
The resulting value is an array of keys that you can pass to the previous function. Additionally, you can use rest parameters in order to allow for multiple path strings to be passed to the function at once.
\\nconst deepGet = (obj, keys) => keys.reduce((xs, x) => xs?.[x] ?? null, obj);\\n\\nconst deepGetByPaths = (obj, ...paths) =>\\n paths.map(path =>\\n deepGet(\\n obj,\\n path\\n .replace(/\\\\[([^\\\\[\\\\]]*)\\\\]/g, \'.$1.\')\\n .split(\'.\')\\n .filter(t => t !== \'\')\\n )\\n );\\n\\nconst data = {\\n foo: {\\n foz: [1, 2, 3],\\n bar: { baz: [\'a\', \'b\', \'c\'] },\\n },\\n};\\ndeepGetByPaths(data, \'foo.foz[2]\', \'foo.bar.baz.1\', \'foo[8]\');\\n// [3, \'b\', null]\\n
Another unusual scenario is searching for a deeply nested property in an object. This is useful when you don\'t know the exact path to the property, but you know the key you\'re looking for. In this case, you can use a recursive function that will search for the key in the object and its nested properties.
\\nFor this scenario, you can use the in
operator to check if the target key exists in the object. If it does, you can return the value of the key. If it doesn\'t, you can use Object.values()
and Array.prototype.reduce()
to recursively call the function on each nested object until the first matching key/value pair is found.
const dig = (obj, target) =>\\n target in obj\\n ? obj[target]\\n : Object.values(obj).reduce((acc, val) => {\\n if (acc !== undefined) return acc;\\n if (typeof val === \'object\') return dig(val, target);\\n }, undefined);\\n\\nconst data = {\\n foo: {\\n foz: [1, 2, 3],\\n bar: { baz: [\'a\', \'b\', \'c\'] },\\n },\\n};\\n\\ndig(data, \'foz\'); // [1, 2, 3]\\ndig(data, \'baz\'); // [\'a\', \'b\', \'c\']\\n
The behavior of dig
when the target key is an integer is to return the value at the given array index. This, in turn, means that the first array to be encountered will be the one that contains the target value.
Last updated: March 22, 2024 View on GitHub
","description":"Working with objects, you\'ll often need to retrieve nested properties. It\'s not uncommon to have deeply nested objects and keys that are calculated dynamically and not known in advance. This means that you\'ll have to dynamically find the value of a nested property based on a key…","guid":"https://www.30secondsofcode.org/js/s/get-nested-object-value","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-03-21T16:00:00.204Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/campfire-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/campfire-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Transform the keys of a JavaScript object","url":"https://www.30secondsofcode.org/js/s/transform-object-keys","content":"JavaScript objects are a fundamental data structure in the language, and they are used to store collections of key-value pairs. Transforming object keys can come in handy in many different situations, but requires a little bit of work to get it right.
\\nGiven an object and a function, you can generate a new object by mapping the keys of the original object using the provided function.
\\nIn order to do so, you can use Object.keys()
to iterate over the object\'s keys and Array.prototype.reduce()
to create a new object with the same values and mapped keys using the provided function, fn
.
const mapKeys = (obj, fn) =>\\n Object.keys(obj).reduce((acc, k) => {\\n acc[fn(obj[k], k, obj)] = obj[k];\\n return acc;\\n }, {});\\n\\nmapKeys({ a: 1, b: 2 }, (val, key) => key + val); // { a1: 1, b2: 2 }\\n
Similarly, you can transform the values of an object using the same approach. Simply use Object.entries()
or Object.values()
instead of Object.keys()
.
The previous snippet only works for the keys at the first level of the object. In order to transform nested keys, you\'ll have to use recursion.
\\nAgain, using Object.keys()
to iterate over the object\'s keys, you can use Array.prototype.reduce()
to create a new object with the same values and mapped keys using the provided function, fn
. If the value of a key is an object, you can call the function recursively to transform its keys as well.
const deepMapKeys = (obj, fn) =>\\n Array.isArray(obj)\\n ? obj.map(val => deepMapKeys(val, fn))\\n : typeof obj === \'object\'\\n ? Object.keys(obj).reduce((acc, current) => {\\n const key = fn(current);\\n const val = obj[current];\\n acc[key] =\\n val !== null && typeof val === \'object\' ? deepMapKeys(val, fn) : val;\\n return acc;\\n }, {})\\n : obj;\\n\\nconst obj = {\\n foo: \'1\',\\n nested: {\\n child: {\\n withArray: [\\n {\\n grandChild: [\'hello\']\\n }\\n ]\\n }\\n }\\n};\\nconst upperKeysObj = deepMapKeys(obj, key => key.toUpperCase());\\n/* {\\n \\"FOO\\":\\"1\\",\\n \\"NESTED\\":{\\n \\"CHILD\\":{\\n \\"WITHARRAY\\":[\\n {\\n \\"GRANDCHILD\\":[ \'hello\' ]\\n }\\n ]\\n }\\n }\\n} */\\n
one of the simplest transformations is renaming the keys of an object. You can use Object.keys()
in combination with Array.prototype.reduce()
and the spread operator (...
) to get the object\'s keys and rename them according to a given dictionary, keysMap
.
const renameKeys = (keysMap, obj) =>\\n Object.keys(obj).reduce(\\n (acc, key) => ({\\n ...acc,\\n ...{ [keysMap[key] || key]: obj[key] }\\n }),\\n {}\\n );\\n\\nconst obj = { name: \'Bobo\', job: \'Front-End Master\', shoeSize: 100 };\\nrenameKeys({ name: \'firstName\', job: \'passion\' }, obj);\\n// { firstName: \'Bobo\', passion: \'Front-End Master\', shoeSize: 100 }\\n
A very common key transformation is to convert all the keys of an object to upper or lower case. In the previous article, can find a more detailed explanation of how to uppercase or lowercase object keys in JavaScript.
\\nSymbols are often underused in JavaScript, but they can be very useful for creating unique keys. In order to symbolize the keys of an object, you can use Object.keys()
to get the keys of the object and Array.prototype.reduce()
to create a new object where each key is converted to a Symbol
.
const symbolizeKeys = obj =>\\n Object.keys(obj).reduce(\\n (acc, key) => ({ ...acc, [Symbol(key)]: obj[key] }),\\n {}\\n );\\n\\nsymbolizeKeys({ id: 10, name: \'apple\' });\\n// { [Symbol(id)]: 10, [Symbol(name)]: \'apple\' }\\n
Subsequently, you might want to completely transform the keys of an object using a function. In that case, you can apply a function against an accumulator and each key in the object (from left to right) using Object.keys()
and Array.prototype.reduce()
.
const transform = (obj, fn, acc) =>\\n Object.keys(obj).reduce((a, k) => fn(a, obj[k], k, obj), acc);\\n\\ntransform(\\n { a: 1, b: 2, c: 1 },\\n (r, v, k) => {\\n (r[v] || (r[v] = [])).push(k);\\n return r;\\n },\\n {}\\n); // { \'1\': [\'a\', \'c\'], \'2\': [\'b\'] }
Last updated: February 19, 2024 View on GitHub
","description":"JavaScript objects are a fundamental data structure in the language, and they are used to store collections of key-value pairs. Transforming object keys can come in handy in many different situations, but requires a little bit of work to get it right.\\n\\nMap object keys\\n\\nGiven an…","guid":"https://www.30secondsofcode.org/js/s/transform-object-keys","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-02-18T16:00:00.787Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/symmetry-cloudy-mountain-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/symmetry-cloudy-mountain-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Formatting numeric values in JavaScript","url":"https://www.30secondsofcode.org/js/s/number-formatting","content":"Number formatting is one of the most common presentational tasks you\'ll encounter coding for the web. While there are some built-in methods, it\'s often necessary to roll up your own solution for a specific use case. Let\'s explore a few common scenarios and how to handle them.
\\nYou may not be familiar with JavaScript\'s numeric separators, which are used in the examples below. They\'re syntactic sugar that make large numeric values more readable.
\\nIn fixed-point notation, a number is represented with a fixed number of digits after the decimal point. However, we often want to remove trailing zeros from the result.
\\nIn order to do so, we can use Number.prototype.toFixed()
to convert the number to a fixed-point notation string. Then, using Number.parseFloat()
, we can convert the fixed-point notation string to a number, removing trailing zeros. Finally, we can use a template literal to convert the number to a string.
const toOptionalFixed = (num, digits) =>\\n `${Number.parseFloat(num.toFixed(digits))}`;\\n\\ntoOptionalFixed(1, 2); // \'1\'\\ntoOptionalFixed(1.001, 2); // \'1\'\\ntoOptionalFixed(1.500, 2); // \'1.5\'\\n
Rounding a number to a specific number of decimal places is pretty common. We can use Math.round()
and template literals to round the number to the specified number of digits. Omitting the second argument, decimals
, will round to an integer.
const round = (n, decimals = 0) =>\\n Number(`${Math.round(`${n}e${decimals}`)}e-${decimals}`);\\n\\nround(1.005, 2); // 1.01\\n
This function returns a number, instead of a string. This is intentional, as we might want to use the rounded number in further calculations.
\\nConverting a number of milliseconds to a human-readable format is a matter of dividing the number by the appropriate values and creating a string for each value. In the following snippet, we\'ll use an object that contains the appropriate values for day
, hour
, minute
, second
, and millisecond
, but you can easily adapt it to different needs.
We can use Object.entries()
with Array.prototype.filter()
to keep only non-zero values. Then, we can use Array.prototype.map()
to create the string for each value, pluralizing appropriately. Finally, we can use Array.prototype.join()
to combine the values into a string.
const formatDuration = ms => {\\n if (ms < 0) ms = -ms;\\n const time = {\\n day: Math.floor(ms / 86_400_000),\\n hour: Math.floor(ms / 3_600_000) % 24,\\n minute: Math.floor(ms / 60_000) % 60,\\n second: Math.floor(ms / 1_000) % 60,\\n millisecond: Math.floor(ms) % 1_000\\n };\\n return Object.entries(time)\\n .filter(val => val[1] !== 0)\\n .map(([key, val]) => `${val} ${key}${val !== 1 ? \'s\' : \'\'}`)\\n .join(\', \');\\n};\\n\\nformatDuration(1_001);\\n// \'1 second, 1 millisecond\'\\nformatDuration(34_325_055_574);\\n// \'397 days, 6 hours, 44 minutes, 15 seconds, 574 milliseconds\'\\n
Similarly, formatting the number of seconds to ISO format is also a handful of divisions away. However, we need to handle the sign separately.
\\nWe can use Array.prototype.map()
in combination with Math.floor()
and String.prototype.padStart()
to convert each segment to a string. Finally, we can use Array.prototype.join()
to combine the values into a string.
const formatSeconds = s => {\\n const [hour, minute, second, sign] =\\n s > 0\\n ? [s / 3_600, (s / 60) % 60, s % 60, \'\']\\n : [-s / 3_600, (-s / 60) % 60, -s % 60, \'-\'];\\n\\n return (\\n sign +\\n [hour, minute, second]\\n .map(v => `${Math.floor(v)}`.padStart(2, \'0\'))\\n .join(\':\')\\n );\\n};\\n\\nformatSeconds(200); // \'00:03:20\'\\nformatSeconds(-200); // \'-00:03:20\'\\nformatSeconds(99_999); // \'27:46:39\'\\n
Formatting a number to a locale-sensitive string is also easy using Number.prototype.toLocaleString()
. This method allows us to format numbers using the local number format separators.
const formatNumber = num => num.toLocaleString();\\n\\nformatNumber(123_456); // \'123,456\' in `en-US`\\nformatNumber(15_675_436_903); // \'15.675.436.903\' in `de-DE`\\n
If we want to format a number to a specific locale, we can pass the locale as the first argument to Number.prototype.toLocaleString()
. For example, here\'s how to format a number to use the decimal mark, using the en-US
locale.
const toDecimalMark = num => num.toLocaleString(\'en-US\');\\n\\ntoDecimalMark(12_305_030_388.9087); // \'12,305,030,388.909\'\\n
When working with currency, it\'s important to use the appropriate formatting. Luckily, JavaScript\'s Intl.NumberFormat
makes this easy, allowing us to format numbers as currency strings. All we have to do is specify the currency and language format, along with style: \'currency\'
.
const toCurrency = (n, curr, LanguageFormat = undefined) =>\\n Intl.NumberFormat(LanguageFormat, {\\n style: \'currency\',\\n currency: curr,\\n }).format(n);\\n\\ntoCurrency(123_456.789, \'EUR\');\\n// €123,456.79 | currency: Euro | currencyLangFormat: Local\\ntoCurrency(123_456.789, \'USD\', \'en-us\');\\n// $123,456.79 | currency: US Dollar | currencyLangFormat: English (USA)\\ntoCurrency(123_456.789, \'USD\', \'fa\');\\n// ۱۲۳٬۴۵۶٫۷۹ $ | currency: US Dollar | currencyLangFormat: Farsi\\ntoCurrency(322_342_436_423.2435, \'JPY\');\\n// ¥322,342,436,423 | currency: Japanese Yen | currencyLangFormat: Local\\ntoCurrency(322_342_436_423.2435, \'JPY\', \'fi\');\\n// 322 342 436 423 ¥ | currency: Japanese Yen | currencyLangFormat: Finnish\\n
Converting a number to its ordinal form (e.g. 1
to 1st
, 2
to 2nd
, 3
to 3rd
, etc.) is also fairly simple, using Intl.PluralRules
. We can use Intl.PluralRules.prototype.select()
to get the correct pluralization category for the number, and then use a lookup dictionary to get the correct suffix. In order for this to produce the correct result, we need to specify the correct locale and the type: \'ordinal\'
option when constructing the object.
const ordinalsEnUS = {\\n one: \'st\',\\n two: \'nd\',\\n few: \'rd\',\\n many: \'th\',\\n zero: \'th\',\\n other: \'th\',\\n};\\n\\nconst toOrdinalSuffix = (num, locale = \'en-US\', ordinals = ordinalsEnUS) => {\\n const pluralRules = new Intl.PluralRules(locale, { type: \'ordinal\' });\\n return `${num}${ordinals[pluralRules.select(num)]}`;\\n};\\n\\ntoOrdinalSuffix(1); // \'1st\'\\ntoOrdinalSuffix(2); // \'2nd\'\\ntoOrdinalSuffix(3); // \'3rd\'\\ntoOrdinalSuffix(4); // \'4th\'\\ntoOrdinalSuffix(123); // \'123rd\'\\n
Last but not least, you might need to pad a number with leading zeros to a specific length. We can use String.prototype.padStart()
to pad the number to the specified length, after converting it to a string.
const padNumber = (n, l) => `${n}`.padStart(l, \'0\');\\n\\npadNumber(1_234, 6); // \'001234\'
Last updated: February 14, 2024 View on GitHub
","description":"Number formatting is one of the most common presentational tasks you\'ll encounter coding for the web. While there are some built-in methods, it\'s often necessary to roll up your own solution for a specific use case. Let\'s explore a few common scenarios and how to handle them.\\n\\n�…","guid":"https://www.30secondsofcode.org/js/s/number-formatting","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-02-13T16:00:00.557Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/white-chapel-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/white-chapel-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Math "],"attachments":null,"extra":null,"language":null},{"title":"Get all unique values in a JavaScript array & remove duplicates","url":"https://www.30secondsofcode.org/js/s/unique-values-in-array-remove-duplicates","content":"Removing duplicates from an array in JavaScript can be done in a variety of ways, such as using Array.prototype.reduce()
, Array.prototype.filter()
or even a simple for
loop. But there\'s a much simpler way to do it, using the built-in Set
object.
A Set
cannot contain duplicate values and can be easily initialized from the values of an array. Then, as it is iterable in itself, we can use the spread operator (...
) to convert it back to an array of just the unique values.
const uniqueElements = arr => [...new Set(arr)];\\n\\nuniqueElements([1, 2, 2, 3, 4, 4, 5]); // [1, 2, 3, 4, 5]\\n
Set
doesn\'t have a length
property, but it does have a size
property, instead. We can use this to check if an array contains duplicates.
const hasDuplicates = arr => arr.length !== new Set(arr).size;\\n\\nhasDuplicates([1, 2, 2, 3, 4, 4, 5]); // true\\nhasDuplicates([1, 2, 3, 4, 5]); // false\\n
Inverting the condition, we can check if all the values of an array are distinct.
\\nconst allDistinct = arr => arr.length === new Set(arr).size;\\n\\nallDistinct([1, 2, 2, 3, 4, 4, 5]); // false\\nallDistinct([1, 2, 3, 4, 5]); // true\\n
If we want to keep only values that are not duplicated, we can use the Array.prototype.filter()
method. Elements that appear more than once have to appear in at least two different indexes, so we can use Array.prototype.indexOf()
and Array.prototype.lastIndexOf()
to check for this. If we expect the array to have many duplicate values, creating a Set
from it first may improve performance.
const removeNonUnique = arr =>\\n [...new Set(arr)].filter(i => arr.indexOf(i) === arr.lastIndexOf(i));\\n\\nremoveNonUnique([1, 2, 2, 3, 4, 4, 5]); // [1, 3, 5]\\n
We can also do the opposite, and remove all values that appear only once. In this case the two indices have to be the same. Notice that using a Set
for this operation will drop duplicates from the result.
const removeUnique = arr =>\\n [...new Set(arr)].filter(i => arr.indexOf(i) !== arr.lastIndexOf(i));\\n\\nremoveUnique([1, 2, 2, 3, 4, 4, 5]); // [2, 4]\\n
More complex data, such as objects, can\'t be compared using equality comparison, so we need to use a function to check for duplicates. Set
objects are not much use here, so we can use Array.prototype.reduce()
and Array.prototype.some()
to manually populate a new array with only the unique values. Using array methods, we can also check if an array contains duplicates, or remove all values that appear more than once.
const uniqueElementsBy = (arr, fn) =>\\n arr.reduce((acc, v) => {\\n if (!acc.some(x => fn(v, x))) acc.push(v);\\n return acc;\\n }, []);\\n\\nconst hasDuplicatesBy = (arr, fn) =>\\n arr.length !== new Set(arr.map(fn)).size;\\n\\nconst removeNonUniqueBy = (arr, fn) =>\\n arr.filter((v, i) => arr.every((x, j) => (i === j) === fn(v, x, i, j)));\\n\\nconst data = [\\n { id: 0, value: \'a\' },\\n { id: 1, value: \'b\' },\\n { id: 2, value: \'c\' },\\n { id: 1, value: \'d\' },\\n { id: 0, value: \'e\' }\\n];\\nconst idComparator = (a, b) => a.id == b.id;\\nconst idMap = a => a.id;\\n\\nuniqueElementsBy(data, idComparator);\\n// [ { id: 0, value: \'a\' }, { id: 1, value: \'b\' }, { id: 2, value: \'c\' } ]\\nhasDuplicatesBy(data, idMap); // true\\nremoveNonUniqueBy(data, idComparator); // [ { id: 2, value: \'c\' } ]
Last updated: January 5, 2024 View on GitHub
","description":"Removing duplicates from an array in JavaScript can be done in a variety of ways, such as using Array.prototype.reduce(), Array.prototype.filter() or even a simple for loop. But there\'s a much simpler way to do it, using the built-in Set object.\\n\\nGet all unique values in an array…","guid":"https://www.30secondsofcode.org/js/s/unique-values-in-array-remove-duplicates","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-01-04T16:00:00.152Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/architectural-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/architectural-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Array "],"attachments":null,"extra":null,"language":null},{"title":"How can I clone an object in JavaScript?","url":"https://www.30secondsofcode.org/js/s/shallow-deep-clone-object","content":"JavaScript\'s primitive data types are immutable, meaning their value cannot change once created. However, objects and arrays are mutable, allowing their value to be altered after creation.
\\nWhat this means in practice is that primitives are passed by value, whereas objects and arrays are passed by reference. Consider the following example:
\\nlet str = \'Hello\';\\nlet copy = str;\\ncopy = \'Hi\';\\n// str = \'Hello\', copy = \'Hi\'\\n\\nlet obj = { a: 1, b: 2 };\\nlet objCopy = obj;\\nobjCopy.b = 4;\\n// obj = { a: 1, b: 4}, objCopy = { a: 1, b: 4 }\\n
As you can see, the object is passed by reference to objCopy
. Changing one of the variables will affect the other one, as they both reference the same object. So how can we remedy this issue? Cloning the object is the answer.
Using the spread operator (...
) or Object.assign()
, we can clone the object and create a new one from its properties.
const shallowClone = obj => Object.assign({}, obj);\\n\\nlet obj = { a: 1, b: 2};\\nlet clone = shallowClone(obj);\\nlet otherClone = shallowClone(obj);\\n\\nclone.b = 4;\\notherClone.b = 6;\\n// obj = { a: 1, b: 2}\\n// clone = { a: 1, b: 4 }\\n// otherClone = { a: 1, b: 6 }\\n
This technique is known as shallow cloning, as it will work for the outer (shallow) object, but fail if we have nested (deep) objects which will ultimately be passed by reference. Which brings us to the next section.
\\nIn order to create a deep clone of an object, we need to recursively clone every nested object, cloning nested objects and arrays along the way.
\\nSome solutions around the web use JSON.stringify()
and JSON.parse()
. While this approach might work in some cases, it\'s plagued by numerous issues and performance problems, so I would advise against using it.
Starting with the edge cases, we need to check if the passed object is null
and, if so, return null
. Otherwise, we can use Object.assign()
and an empty object ({}
) to create a shallow clone of the original.
Next, we\'ll use Object.keys()
and Array.prototype.forEach()
to determine which key-value pairs need to be deep cloned. If the object is an Array
, we\'ll set the clone
\'s length
to that of the original and use Array.from()
to create a clone. Otherwise, we\'ll recursively call the function with the current value as the argument.
const deepClone = obj => {\\n if (obj === null) return null;\\n let clone = Object.assign({}, obj);\\n Object.keys(clone).forEach(\\n key =>\\n (clone[key] =\\n typeof obj[key] === \'object\' ? deepClone(obj[key]) : obj[key])\\n );\\n if (Array.isArray(obj)) {\\n clone.length = obj.length;\\n return Array.from(clone);\\n }\\n return clone;\\n};\\n\\nconst a = { foo: \'bar\', obj: { a: 1, b: 2 } };\\nconst b = deepClone(a); // a !== b, a.obj !== b.obj\\n
This code snippet is designed specifically with plain objects and arrays in mind. This means that it can\'t handle class instances, functions and other special cases. So, how can we handle these cases? JavaScript recently gave us a new tool to solve this problem!
\\nstructuredClone()
Apparently, cloning is a fairly common and important problem. So much so that JavaScript introduced the structuredClone()
global function, which can be used to deep clone objects. Instead of implementing a complicated recursive function, we can simply use this function to clone the object.
const a = { x: 1, y: { y1: \'a\' }, z: new Set([1, 2]) };\\nconst b = structuredClone(a); // a !== b, a.y !== b.y, a.z !== b.z\\n
This technique can be used for both arrays and objects, requires minimal code and is the recommended way of cloning objects in JavaScript, as it\'s the most performant and reliable.
\\nThe structuredClone()
function is a fairly recent addition to the language. Even so, it\'s supported by all modern browsers and Node.js since v17.0.0.
Last updated: January 4, 2024 View on GitHub
","description":"JavaScript\'s primitive data types are immutable, meaning their value cannot change once created. However, objects and arrays are mutable, allowing their value to be altered after creation.\\n\\nWhat this means in practice is that primitives are passed by value, whereas objects and…","guid":"https://www.30secondsofcode.org/js/s/shallow-deep-clone-object","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2024-01-03T16:00:00.422Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/pagodas-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/pagodas-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"Case conversion in JavaScript","url":"https://www.30secondsofcode.org/js/s/string-case-conversion","content":"Different programming languages and frameworks have different conventions for naming variables, functions and classes. It\'s often necessary to convert strings between different cases, which is where this guide comes in.
\\nBefore we can convert a string to a different case, we need to be able to identify the boundaries between words. While a naive approach could rely on spaces or other delimiters to separate words, this approach is not robust enough to handle all cases. Regular expressions provide a far more robust solution to this problem. After much experimentation, I\'ve found the following regular expression to be the most robust:
\\nconst r = /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g;\\n
This looks intimidating even to me, not gonna lie. Let\'s break it down into its constituent parts:
\\n[A-Z]{2,}
matches two or more consecutive uppercase letters. This is useful for identifying acronyms like XML
or HTML
.(?=[A-Z][a-z]+[0-9]*|\\\\b)
is a lookahead assertion that matches a word boundary.[A-Z]?[a-z]+[0-9]*
matches a word starting with an optional uppercase letter, followed by one or more lowercase letters and zero or more digits.[A-Z]
matches a single uppercase letter.[0-9]+
matches one or more digits.g
is a global flag that allows the regular expression to match all occurrences in the string.The camel case naming convention requires that the first letter of each word is capitalized, except for the first word. For example someName
is camel case, but SomeName
is not. This convention is used in JavaScript for naming variables, functions and classes.
In order to convert a string to camel case, we need to:
\\nString.prototype.match()
to break the string into words using an appropriate regexp.Array.prototype.map()
to transform individual words. Use String.prototype.slice()
and String.prototype.toUpperCase()
to capitalize the first letter of each word, and String.prototype.toLowerCase()
to lowercase the rest.Array.prototype.join()
to combine the words into a single string.String.prototype.toLowerCase()
to lowercase the first letter of the final string.const toCamelCase = str => {\\n const s =\\n str &&\\n str\\n .match(\\n /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g\\n )\\n .map(x => x.slice(0, 1).toUpperCase() + x.slice(1).toLowerCase())\\n .join(\'\');\\n return s.slice(0, 1).toLowerCase() + s.slice(1);\\n};\\n\\ntoCamelCase(\'some_database_field_name\'); // \'someDatabaseFieldName\'\\ntoCamelCase(\'Some label that needs to be camelized\');\\n// \'someLabelThatNeedsToBeCamelized\'\\ntoCamelCase(\'some-javascript-property\'); // \'someJavascriptProperty\'\\ntoCamelCase(\'some-mixed_string with spaces_underscores-and-hyphens\');\\n// \'someMixedStringWithSpacesUnderscoresAndHyphens\'\\n
Pascal case is most often used in object-oriented languages like Java or C#. Pascal case strings have the first letter of each word capitalized. For example SomeName
is Pascal case, but someName
is not.
The process for converting a string to Pascal case is very similar to the process for converting a string to camel case. The only difference is that we don\'t need to lowercase the first letter of the final string.
\\nconst toPascalCase = str =>\\n str\\n .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)\\n .map(x => x.slice(0, 1).toUpperCase() + x.slice(1).toLowerCase())\\n .join(\'\');\\n\\ntoPascalCase(\'some_database_field_name\'); // \'SomeDatabaseFieldName\'\\ntoPascalCase(\'Some label that needs to be pascalized\');\\n// \'SomeLabelThatNeedsToBePascalized\'\\ntoPascalCase(\'some-javascript-property\'); // \'SomeJavascriptProperty\'\\ntoPascalCase(\'some-mixed_string with spaces_underscores-and-hyphens\');\\n// \'SomeMixedStringWithSpacesUnderscoresAndHyphens\'\\n
Kebab case is most often used in URL slugs. Kebab case strings are all lowercase, with words separated by hyphens. For example some-name
is kebab case, but some-Name
is not.
In order to convert a string to kebab case, we need only lowercase each word and join them with a hyphen.
\\nconst toKebabCase = str =>\\n str &&\\n str\\n .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)\\n .map(x => x.toLowerCase())\\n .join(\'-\');\\n\\ntoKebabCase(\'camelCase\'); // \'camel-case\'\\ntoKebabCase(\'some text\'); // \'some-text\'\\ntoKebabCase(\'some-mixed_string With spaces_underscores-and-hyphens\');\\n// \'some-mixed-string-with-spaces-underscores-and-hyphens\'\\ntoKebabCase(\'AllThe-small Things\'); // \'all-the-small-things\'\\ntoKebabCase(\'IAmEditingSomeXMLAndHTML\');\\n// \'i-am-editing-some-xml-and-html\'\\n
Snake case is most often used in languages such as Python or Ruby. Snake case strings are all lowercase, with words separated by underscores. For example some_name
is snake case, but some_Name
is not.
The process for converting a string to snake case is the same as the one for kebab case, where the hyphens are replaced with underscores.
\\nconst toSnakeCase = str =>\\n str &&\\n str\\n .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)\\n .map(x => x.toLowerCase())\\n .join(\'_\');\\n\\ntoSnakeCase(\'camelCase\'); // \'camel_case\'\\ntoSnakeCase(\'some text\'); // \'some_text\'\\ntoSnakeCase(\'some-mixed_string With spaces_underscores-and-hyphens\');\\n// \'some_mixed_string_with_spaces_underscores_and_hyphens\'\\ntoSnakeCase(\'AllThe-small Things\'); // \'all_the_small_things\'\\ntoSnakeCase(\'IAmEditingSomeXMLAndHTML\');\\n// \'i_am_editing_some_xml_and_html\'\\n
Title case is most often used in titles or headings. Title case strings have the first letter of each word capitalized, with words separated by spaces. For example Some Name
is title case, but Some name
is not.
The conversion process is the same as Pascal case, except that the delimiter is a space instead of an empty string.
\\nconst toTitleCase = str =>\\n str\\n .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)\\n .map(x => x.slice(0, 1).toUpperCase() + x.slice(1))\\n .join(\' \');\\n\\ntoTitleCase(\'some_database_field_name\'); // \'Some Database Field Name\'\\ntoTitleCase(\'Some label that needs to be title-cased\');\\n// \'Some Label That Needs To Be Title Cased\'\\ntoTitleCase(\'some-package-name\'); // \'Some Package Name\'\\ntoTitleCase(\'some-mixed_string with spaces_underscores-and-hyphens\');\\n// \'Some Mixed String With Spaces Underscores And Hyphens\'\\n
Finally, sentence case is most often used in sentences. Sentence case strings have their first letter capitalized, with words separated by spaces. For example Some name
is sentence case, but some Name
is not.
As sentence case is pretty lenient, we need only make sure the delimiter is a space and that the first letter of the string is capitalized.
\\nconst toSentenceCase = str => {\\n const s =\\n str &&\\n str\\n .match(\\n /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g\\n )\\n .join(\' \');\\n return s.slice(0, 1).toUpperCase() + s.slice(1);\\n};\\n\\ntoSentenceCase(\'some_database_field_name\'); // \'Some database field name\'\\ntoSentenceCase(\'Some label that needs to be title-cased\');\\n// \'Some label that needs to be title cased\'\\ntoSentenceCase(\'some-package-name\'); // \'Some package name\'\\ntoSentenceCase(\'some-mixed_string with spaces_underscores-and-hyphens\');\\n// \'Some mixed string with spaces underscores and hyphens\'\\n
That was a lot of code snippets to go through and you might need more than one in your project. Let\'s see if we can combine them all into one function that expects a case as an argument and returns the converted string.
\\nconst convertCase = (str, toCase = \'camel\') => {\\n if (!str) return \'\';\\n\\n const delimiter =\\n toCase === \'snake\'\\n ? \'_\'\\n : toCase === \'kebab\'\\n ? \'-\'\\n : [\'title\', \'sentence\'].includes(toCase)\\n ? \' \'\\n : \'\';\\n\\n const transform = [\'camel\', \'pascal\'].includes(toCase)\\n ? x => x.slice(0, 1).toUpperCase() + x.slice(1).toLowerCase()\\n : [\'snake\', \'kebab\'].includes(toCase)\\n ? x => x.toLowerCase()\\n : toCase === \'title\'\\n ? x => x.slice(0, 1).toUpperCase() + x.slice(1)\\n : x => x;\\n\\n const finalTransform =\\n toCase === \'camel\'\\n ? x => x.slice(0, 1).toLowerCase() + x.slice(1)\\n : toCase === \'sentence\'\\n ? x => x.slice(0, 1).toUpperCase() + x.slice(1)\\n : x => x;\\n\\n const words = str.match(\\n /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\\\\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g\\n );\\n\\n return finalTransform(words.map(transform).join(delimiter));\\n};\\n\\nconvertCase(\'mixed_string with spaces_underscores-and-hyphens\', \'camel\');\\n// \'mixedStringWithSpacesUnderscoresAndHyphens\'\\nconvertCase(\'mixed_string with spaces_underscores-and-hyphens\', \'pascal\');\\n// \'MixedStringWithSpacesUnderscoresAndHyphens\'\\nconvertCase(\'mixed_string with spaces_underscores-and-hyphens\', \'kebab\');\\n// \'mixed-string-with-spaces-underscores-and-hyphens\'\\nconvertCase(\'mixed_string with spaces_underscores-and-hyphens\', \'snake\');\\n// \'mixed_string_with_spaces_underscores_and_hyphens\'\\nconvertCase(\'mixed_string with spaces_underscores-and-hyphens\', \'title\');\\n// \'Mixed String With Spaces Underscores And Hyphens\'\\nconvertCase(\'mixed_string with spaces_underscores-and-hyphens\', \'sentence\');\\n// \'Mixed string with spaces underscores and hyphens\'\\n
While this code certainly is complex, it builds on top of all previous snippets, conditionally applying the appropriate transformations based on the desired case. As many of the snippets share similarities, the conditions are condensed as much as possible to avoid repetition.
Last updated: December 31, 2023 View on GitHub
","description":"Different programming languages and frameworks have different conventions for naming variables, functions and classes. It\'s often necessary to convert strings between different cases, which is where this guide comes in.\\n\\nWord boundary identification\\n\\nBefore we can convert a string…","guid":"https://www.30secondsofcode.org/js/s/string-case-conversion","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2023-12-30T16:00:00.109Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/chubby-squirrel-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/chubby-squirrel-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," String "],"attachments":null,"extra":null,"language":null},{"title":"Calculate SHA-256 hash in JavaScript","url":"https://www.30secondsofcode.org/js/s/hash-sha-256","content":"The SHA-256 algorithm is a widely used hash function producing a 256-bit hash value. It is used in many security applications and protocols, including TLS and SSL, SSH, PGP, and Bitcoin.
\\nCalculating a SHA-256 hash in JavaScript is easy using native APIs, but there are some differences between the browser and Node.js. As the browser implementation is asynchronous, both of the examples provided will return a Promise
for consistency.
In the browser, you can use the SubtleCrypto API to create a hash for the given value. You first need to create a new TextEncoder
and use it to encode the given value. Then, pass its value to SubtleCrypto.digest()
to generate a digest of the given data, resulting in a Promise
.
As the promise resolves to an ArrayBuffer
, you will need to read the data using DataView.prototype.getUint32()
. Then, you need to convert the data to its hexadecimal representation using Number.prototype.toString()
. Add the data to an array using Array.prototype.push()
. Finally, use Array.prototype.join()
to combine values in the array of hexes
into a string.
const hashValue = val =>\\n crypto.subtle\\n .digest(\'SHA-256\', new TextEncoder(\'utf-8\').encode(val))\\n .then(h => {\\n let hexes = [],\\n view = new DataView(h);\\n for (let i = 0; i < view.byteLength; i += 4)\\n hexes.push((\'00000000\' + view.getUint32(i).toString(16)).slice(-8));\\n return hexes.join(\'\');\\n });\\n\\nhashValue(\\n JSON.stringify({ a: \'a\', b: [1, 2, 3, 4], foo: { c: \'bar\' } })\\n).then(console.log);\\n// \'04aa106279f5977f59f9067fa9712afc4aedc6f5862a8defc34552d8c7206393\'\\n
In Node.js, you can use the crypto
module to create a hash for the given value. You first need to create a Hash
object with the appropriate algorithm using crypto.createHash()
. Then, use hash.update()
to add the data from val
to the Hash
and hash.digest()
to calculate the digest of the data.
For consistency with the browser implementation and to prevent blocking on a long operation, we\'ll return a Promise
by wrapping it in setTimeout()
.
import { createHash } from \'crypto\';\\n\\nconst hashValue = val =>\\n new Promise(resolve =>\\n setTimeout(\\n () => resolve(createHash(\'sha256\').update(val).digest(\'hex\')),\\n 0\\n )\\n );\\n\\nhashValue(JSON.stringify({ a: \'a\', b: [1, 2, 3, 4], foo: { c: \'bar\' } })).then(\\n console.log\\n);\\n// \'04aa106279f5977f59f9067fa9712afc4aedc6f5862a8defc34552d8c7206393\'\\n
Last updated: October 7, 2023 View on GitHub
","description":"The SHA-256 algorithm is a widely used hash function producing a 256-bit hash value. It is used in many security applications and protocols, including TLS and SSL, SSH, PGP, and Bitcoin.\\n\\nCalculating a SHA-256 hash in JavaScript is easy using native APIs, but there are some…","guid":"https://www.30secondsofcode.org/js/s/hash-sha-256","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2023-10-06T16:00:00.570Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/padlocks-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/padlocks-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Browser "],"attachments":null,"extra":null,"language":null},{"title":"The many ways to initialize a JavaScript Array","url":"https://www.30secondsofcode.org/js/s/array-initialization","content":"Initializing arrays in JavaScript is a crucial task, with many techniques to choose from and performance considerations to keep in mind. While there might not be a one-size-fits-all solution, there are a few options you might want to consider.
\\nThe first thing you\'d reach for would probably be the Array()
constructor. Counterintuitively, this is probably the most problematic option to use on its own. While it works for any number of arguments to create an array with the given values, it falls short pretty much everywhere else. Most of its problems stem from holes or \\"empty\\" values with which the resulting array is populated and how these are handled elsewhere.
const arr = Array(3); // [ , , ] - 3 empty slots\\narr.map(() => 1); // [ , , ] - map() skips empty slots\\narr.map((_, i) => i); // [ , , ] - map() skips empty slots\\narr[0]; // undefined - actually, it is an empty slot\\n
Array.from()
is a static method that creates a new, shallow-copied Array instance from an array-like or iterable object. It is very useful for converting array-like objects (e.g. arguments
, NodeList
) or iterables (e.g. Set
, Map
, Generator
) into actual arrays. Apart from that, it can easily be \\"tricked\\" into creating an array of a given length by passing an object with a length
property. This is somewhat slow, but it works well and circumvents some of the problems of the Array()
constructor. Additionally, it allows you to pass a mapping function as a second argument, which is very useful for initializing arrays with values.
const arr = Array.from({ length: 3 }); // [undefined, undefined, undefined]\\narr.map(() => 1); // [1, 1, 1]\\narr.map((_, i) => i); // [0, 1, 2]\\nconst staticArr = Array.from({ length: 3 }, () => 1); // [1, 1, 1]\\nconst indexArr = Array.from({ length: 3 }, (_, i) => i); // [0, 1, 2]\\n
While Array.from()
is quite flexible, using a mapping function to fill it with the same value isn\'t particularly efficient. Array.prototype.fill()
comes to fill this gap by allowing you to fill an existing array with the same value. This can also come in handy in conjunction with the Array()
constructor, as it allows you to fill the array with a value, instead of empty slots.
const nullArr = new Array(3).fill(null); // [null, null, null]\\nconst staticArr = Array.from({ length: 3 }).fill(1); // [1, 1, 1]\\nconst indexArr = Array(3).fill(null).map((_, i) => i); // [0, 1, 2]\\n
Array.from()
allows for a mapping function via a second argument, but a lot of people think it\'s hard to read. Additionally, there are a few edge cases, where having access to the array itself during mapping can be useful. Array.prototype.map()
gives you this little bit of extra flexbility and readability if that\'s what you\'re concerned about. It\'s also able to do pretty much everything else you might need, but remember that it doesn\'t work well with empty values.
const arr = Array(3).map(() => 1); // [ , , ] - map() skips empty slots\\nconst staticArr = Array.from({ length: 3 }).map(() => 1); // [1, 1, 1]\\nconst indexArr = Array.from({ length: 3 }).map((_, i) => i); // [0, 1, 2]\\nconst fractionArr =\\n Array.from({ length: 3 }).map((_, i, a) => i / a.length); // [0, 0.5, 1]\\n
Performance might be a concern if this sort of operation is very common in your application, but overall none of these options are particularly slow. The Array()
constructor seems to be the fastest. That being said, if combined with Array.prototype.fill()
, it can be the best option for initializing an array with a single value. Oddly enough, this performance advantage still holds even if you chain an Array.prototype.map()
call afterwards to create dynamic values. Therefore, my personal recommendations are as follows:
const initializeArrayWithValues = (n, val = 0) => Array(n).fill(val);\\nconst initializeMappedArray = (n, mapFn = (_, i) => i) =>\\n Array(n).fill(null).map(mapFn);\\n\\ninitializeArrayWithValues(4, 2); // [2, 2, 2, 2]\\ninitializeMappedArray(4, (_, i) => i * 2); // [0, 2, 4, 6]\\n
You can learn more tips and tricks related to JavaScript array initialization in this collection.
Last updated: June 18, 2023 View on GitHub
","description":"Initializing arrays in JavaScript is a crucial task, with many techniques to choose from and performance considerations to keep in mind. While there might not be a one-size-fits-all solution, there are a few options you might want to consider.\\n\\nArray() constructor\\n\\nThe first thing…","guid":"https://www.30secondsofcode.org/js/s/array-initialization","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2023-06-17T16:00:00.745Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/red-mountain-range-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/red-mountain-range-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Array "],"attachments":null,"extra":null,"language":null},{"title":"What is the Event Loop in JavaScript?","url":"https://www.30secondsofcode.org/js/s/event-loop-explained","content":"The Event Loop is a source of confusion for many developers, but it\'s a fundamental piece of the JavaScript engine. It\'s what allows JavaScript to be single-threaded, yet able to execute in a non-blocking fashion. To understand the Event Loop, we first need to explain a few things about the JavaScript engine, such as the Call Stack, Tasks, Microtasks and their respective Queues. Let\'s break them down one by one.
\\nThe Call Stack is a data structure that keeps track of the execution of JavaScript code. As the name suggests, it\'s a stack, thus a LIFO (Last In, First Out) data structure in memory. Each function that\'s executed is represented as a frame in the Call Stack and placed on top of the previous function.
\\nLet\'s look at a simple example, step by step:
\\nfunction foo() {\\n console.log(\'foo\');\\n bar();\\n}\\n\\nfunction bar() {\\n console.log(\'bar\');\\n}\\n
foo()
is pushed onto the Call Stack.foo()
is executed and popped off the Call Stack.console.log(\'foo\')
is pushed onto the Call Stack.console.log(\'foo\')
is executed and popped off the Call Stack.bar()
is pushed onto the Call Stack.bar()
is executed and popped off the Call Stack.console.log(\'bar\')
is pushed onto the Call Stack.console.log(\'bar\')
is executed and popped off the Call Stack.Tasks are scheduled, synchronous blocks of code. While executing, they have exclusive access to the Call Stack and can also enqueue other tasks. Between Tasks, the browser can perform rendering updates. Tasks are stored in the Task Queue, waiting to be executed by their associated functions. The Task Queue, in turn, is a FIFO (First In, First Out) data structure. Examples of Tasks include the callback function of an event listener associated with an event and the callback of setTimeout()
.
Microtasks are similar to Tasks in that they\'re scheduled, synchronous blocks of code with exclusive access to the Call Stack while executing. Additionally, they are stored in their own FIFO (First In, First Out) data structure, the Microtask Queue. Microtasks differ from Tasks, however, in that the Microtask Queue must be emptied out after a Task completes and before re-rendering. Examples of Microtasks include Promise
callbacks and MutationObserver
callbacks.
Microtasks and the Microtask Queue are also referred to as Jobs and the Job Queue.
\\nFinally, the Event Loop is a loop that keeps running and checks if the Call Stack is empty. It processes Tasks and Microtasks, by placing them in the Call Stack one at a time and also controls the rendering process. It\'s made up of four key steps:
\\nTo better understand the Event Loop, let\'s look at a practical example, incorporating all of the above concepts:
\\nconsole.log(\'Script start\');\\n\\nsetTimeout(() => console.log(\'setTimeout()\'), 0);\\n\\nPromise.resolve()\\n .then(() => console.log(\'Promise.then() #1\'))\\n .then(() => console.log(\'Promise.then() #2\'));\\n\\nconsole.log(\'Script end\');\\n\\n// LOGS:\\n// Script start\\n// Script end\\n// Promise.then() #1\\n// Promise.then() #2\\n// setTimeout()\\n
Does the output look like what you expected? Let\'s break down what\'s happening, step by step:
\\nconsole.log()
is pushed to the Call Stack and executed, logging \'Script start\'
.setTimeout()
is pushed to the Call Stack and executed. This creates a new Task for its callback function in the Task Queue.Promise.prototype.resolve()
is pushed to the Call Stack and executed, calling in turn Promise.prototype.then()
.Promise.prototype.then()
is pushed to the Call Stack and executed. This creates a new Microtask for its callback function in the Microtask Queue.console.log()
is pushed to the Call Stack and executed, logging \'Script end\'
.Promise.prototype.then()
that was queued in step 5.console.log()
is pushed to the Call Stack and executed, logging \'Promise.then() #1\'
.Promise.prototype.then()
is pushed to the Call Stack and executed. This creates a new entry for its callback function in the Microtask Queue.Promise.prototype.then()
that was queued in step 9.console.log()
is pushed to the Call Stack and executed, logging \'Promise.then() #2\'
.setTimeout()
that was queued in step 3.console.log()
is pushed to the Call Stack and executed, logging \'setTimeout()\'
.setTimeout()
indicates a minimum time until execution, not a guaranteed time. This is due to the fact that Tasks execute in order and that Microtasks may be executed in-between.Last updated: August 21, 2022 View on GitHub
","description":"The Event Loop is a source of confusion for many developers, but it\'s a fundamental piece of the JavaScript engine. It\'s what allows JavaScript to be single-threaded, yet able to execute in a non-blocking fashion. To understand the Event Loop, we first need to explain a few…","guid":"https://www.30secondsofcode.org/js/s/event-loop-explained","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2022-08-20T16:00:00.671Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/tranquility-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/tranquility-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Browser "],"attachments":null,"extra":null,"language":null},{"title":"Can I use an object as an array without modifying it in JavaScript?","url":"https://www.30secondsofcode.org/js/s/object-array-proxy","content":"The other day, I stumbled upon some code where I needed to handle an object as a regular array a few times. This was, of course, achievable using Object.keys()
, Object.values()
or Object.entries()
, but it started getting verbose real quick.
So I thought I could create some kind of wrapper that would take an object and define some array-like behavior for it. I was mainly in need of Array.prototype.map()
, Array.prototype.find()
, Array.prototype.includes()
and Array.prototype.length
. All of this functionality was pretty straightforward to create using Object
methods. The only tricky part, so to speak, was getting the object to behave as an iterable, which required using the Symbol.iterator
and a generator function.
Injecting the new functionality into an object could be as simple as adding the methods to it. The downside of this approach is that they would be part of the actual object, which can be problematic. It also doesn\'t help that this is not very reusable if we want to apply this over a handful of objects.
\\nEnter the Proxy object, one of the lesser known tools in a JavaScript developer\'s tool belt, yet a very powerful one. It\'s used to intercept certain operations for an object, such as property lookup, assignment etc. In this scenario, it can neatly wrap the required functionality into a function that creates a proxy around the object.
\\nThe final code, long as it may be, can be seen in the example below. It implements the functionality I needed, as well as a handful more Array
methods for good measure:
const toKeyedArray = obj => {\\n const methods = {\\n map(target) {\\n return callback =>\\n Object.keys(target).map(key => callback(target[key], key, target));\\n },\\n reduce(target) {\\n return (callback, accumulator) =>\\n Object.keys(target).reduce(\\n (acc, key) => callback(acc, target[key], key, target),\\n accumulator\\n );\\n },\\n forEach(target) {\\n return callback =>\\n Object.keys(target).forEach(key => callback(target[key], key, target));\\n },\\n filter(target) {\\n return callback =>\\n Object.keys(target).reduce((acc, key) => {\\n if (callback(target[key], key, target)) acc[key] = target[key];\\n return acc;\\n }, {});\\n },\\n slice(target) {\\n return (start, end) => Object.values(target).slice(start, end);\\n },\\n find(target) {\\n return callback => {\\n return (Object.entries(target).find(([key, value]) =>\\n callback(value, key, target)\\n ) || [])[0];\\n };\\n },\\n findKey(target) {\\n return callback =>\\n Object.keys(target).find(key => callback(target[key], key, target));\\n },\\n includes(target) {\\n return val => Object.values(target).includes(val);\\n },\\n keyOf(target) {\\n return value =>\\n Object.keys(target).find(key => target[key] === value) || null;\\n },\\n lastKeyOf(target) {\\n return value =>\\n Object.keys(target)\\n .reverse()\\n .find(key => target[key] === value) || null;\\n },\\n };\\n const methodKeys = Object.keys(methods);\\n\\n const handler = {\\n get(target, prop, receiver) {\\n if (methodKeys.includes(prop)) return methods[prop](...arguments);\\n const [keys, values] = [Object.keys(target), Object.values(target)];\\n if (prop === \'length\') return keys.length;\\n if (prop === \'keys\') return keys;\\n if (prop === \'values\') return values;\\n if (prop === Symbol.iterator)\\n return function* () {\\n for (value of values) yield value;\\n return;\\n };\\n else return Reflect.get(...arguments);\\n },\\n };\\n\\n return new Proxy(obj, handler);\\n};\\n\\n// Object creation\\nconst x = toKeyedArray({ a: \'A\', b: \'B\' });\\n\\n// Accessing properties and values\\nx.a; // \'A\'\\nx.keys; // [\'a\', \'b\']\\nx.values; // [\'A\', \'B\']\\n[...x]; // [\'A\', \'B\']\\nx.length; // 2\\n\\n// Inserting values\\nx.c = \'c\'; // x = { a: \'A\', b: \'B\', c: \'c\' }\\nx.length; // 3\\n\\n// Array methods\\nx.forEach((v, i) => console.log(`${i}: ${v}`)); // LOGS: \'a: A\', \'b: B\', \'c: c\'\\nx.map((v, i) => i + v); // [\'aA\', \'bB, \'cc]\\nx.filter((v, i) => v !== \'B\'); // { a: \'A\', c: \'c\' }\\nx.reduce((a, v, i) => ({ ...a, [v]: i }), {}); // { A: \'a\', B: \'b\', c: \'c\' }\\nx.slice(0, 2); // [\'A\', \'B\']\\nx.slice(-1); // [\'c\']\\nx.find((v, i) => v === i); // \'c\'\\nx.findKey((v, i) => v === \'B\'); // \'b\'\\nx.includes(\'c\'); // true\\nx.includes(\'d\'); // false\\nx.keyOf(\'B\'); // \'b\'\\nx.keyOf(\'a\'); // null\\nx.lastKeyOf(\'c\'); // \'c\'
Last updated: September 27, 2021 View on GitHub
","description":"The other day, I stumbled upon some code where I needed to handle an object as a regular array a few times. This was, of course, achievable using Object.keys(), Object.values() or Object.entries(), but it started getting verbose real quick.\\n\\nSo I thought I could create some kind…","guid":"https://www.30secondsofcode.org/js/s/object-array-proxy","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2021-09-26T16:00:00.624Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/birds-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/birds-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Object "],"attachments":null,"extra":null,"language":null},{"title":"What are JavaScript Iterators and where can I use them?","url":"https://www.30secondsofcode.org/js/s/iterators","content":"JavaScript iterators were introduced in ES6 and they are used to loop over a sequence of values, usually some sort of collection. By definition, an iterator must implement a next()
function, that returns an object in the form of { value, done }
where value
is the next value in the iteration sequence and done
is a boolean determining if the sequence has already been consumed.
A very simple iterator with practical use in a real-world project could be as follows:
\\nclass LinkedList {\\n constructor(data) {\\n this.data = data;\\n }\\n\\n firstItem() {\\n return this.data.find(i => i.head);\\n }\\n\\n findById(id) {\\n return this.data.find(i => i.id === id);\\n }\\n\\n [Symbol.iterator]() {\\n let item = { next: this.firstItem().id };\\n return {\\n next: () => {\\n item = this.findById(item.next);\\n if (item) {\\n return { value: item.value, done: false };\\n }\\n return { value: undefined, done: true };\\n },\\n };\\n }\\n}\\n\\nconst myList = new LinkedList([\\n { id: \'a10\', value: \'First\', next: \'a13\', head: true },\\n { id: \'a11\', value: \'Last\', next: null, head: false },\\n { id: \'a12\', value: \'Third\', next: \'a11\', head: false },\\n { id: \'a13\', value: \'Second\', next: \'a12\', head: false },\\n]);\\n\\nfor (let item of myList) {\\n console.log(item); // \'First\', \'Second\', \'Third\', \'Last\'\\n}\\n
In the above example, we implement a LinkedList
data structure, that internally uses a data
array. Each item in it has a value
and some implementation-specific properties used to determine its position in the sequence. Objects constructed from this class are not iterable by default. To define an iterator we use Symbol.iterator
and set it up so that the returned sequence is in order based on the internal implementation of the class, while the returned items only return their value
.
On a related note, iterators are just functions, meaning they can be called like any other function (e.g. to delegate the iteration to an existing iterator), while also not being restricted to the Symbol.iterator
name. This allows us to define multiple iterators for the same object. Here\'s an example of these concepts at play:
class SpecialList {\\n constructor(data) {\\n this.data = data;\\n }\\n\\n [Symbol.iterator]() {\\n return this.data[Symbol.iterator]();\\n }\\n\\n values() {\\n return this.data\\n .filter(i => i.complete)\\n .map(i => i.value)\\n [Symbol.iterator]();\\n }\\n}\\n\\nconst myList = new SpecialList([\\n { complete: true, value: \'Lorem ipsum\' },\\n { complete: true, value: \'dolor sit amet\' },\\n { complete: false },\\n { complete: true, value: \'adipiscing elit\' },\\n]);\\n\\nfor (let item of myList) {\\n console.log(item); // The exact data passed to the SpecialList constructor above\\n}\\n\\nfor (let item of myList.values()) {\\n console.log(item); // \'Lorem ipsum\', \'dolor sit amet\', \'adipiscing elit\'\\n}\\n
In this example, we use the native array iterator of the data
object to make our SpecialList
iterable, returning the exact values of the data
array. Meanwhile, we also define a values
method, which is an iterator itself, using Array.prototype.filter()
and Array.prototype.map()
on the data
array. Finally, we return the Symbol.iterator
of the result, allowing iteration only over non-empty objects in the sequence and returning just the value
for each one.
Last updated: September 26, 2021 View on GitHub
","description":"JavaScript iterators were introduced in ES6 and they are used to loop over a sequence of values, usually some sort of collection. By definition, an iterator must implement a next() function, that returns an object in the form of { value, done } where value is the next value in…","guid":"https://www.30secondsofcode.org/js/s/iterators","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2021-09-25T16:00:00.712Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/balloons-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/balloons-800.webp","type":"photo","width":360,"height":180}],"categories":[" JavaScript "," Array "],"attachments":null,"extra":null,"language":null},{"title":"JavaScript Data Structures - Graph","url":"https://www.30secondsofcode.org/js/s/data-structures-graph","content":"A graph is a data structure consisting of a set of nodes or vertices and a set of edges that represent connections between those nodes. Graphs can be directed or undirected, while their edges can be assigned numeric weights.
\\nEach node in a graph data structure must have the following properties:
\\nkey
: The key of the nodevalue
: The value of the nodeEach edge in a graph data structure must have the following properties:
\\na
: The starting node of the edgeb
: The target node of the edgeweight
: An optional numeric weight value for the edgeThe main operations of a graph data structure are:
\\naddNode
: Inserts a new node with the specific key and valueaddEdge
: Inserts a new edge between two given nodes, optionally setting its weightremoveNode
: Removes the node with the specified keyremoveEdge
: Removes the edge between two given nodesfindNode
: Retrieves the node with the given keyhasEdge
: Checks if the graph has an edge between two given nodessetEdgeWeight
: Sets the weight of a given edgegetEdgeWeight
: Gets the weight of a given edgeadjacent
: Finds all nodes for which an edge exists from a given nodeindegree
: Calculates the total number of edges to a given nodeoutdegree
: Calculates the total number of edges from a given nodeclass Graph {\\n constructor(directed = true) {\\n this.directed = directed;\\n this.nodes = [];\\n this.edges = new Map();\\n }\\n\\n addNode(key, value = key) {\\n this.nodes.push({ key, value });\\n }\\n\\n addEdge(a, b, weight) {\\n this.edges.set(JSON.stringify([a, b]), { a, b, weight });\\n if (!this.directed)\\n this.edges.set(JSON.stringify([b, a]), { a: b, b: a, weight });\\n }\\n\\n removeNode(key) {\\n this.nodes = this.nodes.filter(n => n.key !== key);\\n [...this.edges.values()].forEach(({ a, b }) => {\\n if (a === key || b === key) this.edges.delete(JSON.stringify([a, b]));\\n });\\n }\\n\\n removeEdge(a, b) {\\n this.edges.delete(JSON.stringify([a, b]));\\n if (!this.directed) this.edges.delete(JSON.stringify([b, a]));\\n }\\n\\n findNode(key) {\\n return this.nodes.find(x => x.key === key);\\n }\\n\\n hasEdge(a, b) {\\n return this.edges.has(JSON.stringify([a, b]));\\n }\\n\\n setEdgeWeight(a, b, weight) {\\n this.edges.set(JSON.stringify([a, b]), { a, b, weight });\\n if (!this.directed)\\n this.edges.set(JSON.stringify([b, a]), { a: b, b: a, weight });\\n }\\n\\n getEdgeWeight(a, b) {\\n return this.edges.get(JSON.stringify([a, b])).weight;\\n }\\n\\n adjacent(key) {\\n return [...this.edges.values()].reduce((acc, { a, b }) => {\\n if (a === key) acc.push(b);\\n return acc;\\n }, []);\\n }\\n\\n indegree(key) {\\n return [...this.edges.values()].reduce((acc, { a, b }) => {\\n if (b === key) acc++;\\n return acc;\\n }, 0);\\n }\\n\\n outdegree(key) {\\n return [...this.edges.values()].reduce((acc, { a, b }) => {\\n if (a === key) acc++;\\n return acc;\\n }, 0);\\n }\\n}\\n
Create a class
with a constructor
that initializes an empty array, nodes
, and a Map
, edges
, for each instance. The optional argument, directed
, specifies if the graph is directed or not.
Define an addNode()
method, which uses Array.prototype.push()
to add a new node in the nodes
array.
Define an addEdge()
method, which uses Map.prototype.set()
to add a new edge to the edges
Map, using JSON.stringify()
to produce a unique key.
Define a removeNode()
method, which uses Array.prototype.filter()
and Map.prototype.delete()
to remove the given node and any edges connected to it.
Define a removeEdge()
method, which uses Map.prototype.delete()
to remove the given edge.
Define a findNode()
method, which uses Array.prototype.find()
to return the given node, if any.
Define a hasEdge()
method, which uses Map.prototype.has()
and JSON.stringify()
to check if the given edge exists in the edges
Map.
Define a setEdgeWeight()
method, which uses Map.prototype.set()
to set the weight of the appropriate edge, whose key is produced by JSON.stringify()
.
Define a getEdgeWeight()
method, which uses Map.prototype.get()
to get the eight of the appropriate edge, whose key is produced by JSON.stringify()
.
Define an adjacent()
method, which uses Map.prototype.values()
, Array.prototype.reduce()
and Array.prototype.push()
to find all nodes connected to the given node.
Define an indegree()
method, which uses Map.prototype.values()
and Array.prototype.reduce()
to count the number of edges to the given node.
Define an outdegree()
method, which uses Map.prototype.values()
and Array.prototype.reduce()
to count the number of edges from the given node.
const g = new Graph();\\n\\ng.addNode(\'a\');\\ng.addNode(\'b\');\\ng.addNode(\'c\');\\ng.addNode(\'d\');\\n\\ng.addEdge(\'a\', \'c\');\\ng.addEdge(\'b\', \'c\');\\ng.addEdge(\'c\', \'b\');\\ng.addEdge(\'d\', \'a\');\\n\\ng.nodes.map(x => x.value); // [\'a\', \'b\', \'c\', \'d\']\\n[...g.edges.values()].map(({ a, b }) => `${a} => ${b}`);\\n// [\'a => c\', \'b => c\', \'c => b\', \'d => a\']\\n\\ng.adjacent(\'c\'); // [\'b\']\\n\\ng.indegree(\'c\'); // 2\\ng.outdegree(\'c\'); // 1\\n\\ng.hasEdge(\'d\', \'a\'); // true\\ng.hasEdge(\'a\', \'d\'); // false\\n\\ng.removeEdge(\'c\', \'b\');\\n\\n[...g.edges.values()].map(({ a, b }) => `${a} => ${b}`);\\n// [\'a => c\', \'b => c\', \'d => a\']\\n\\ng.removeNode(\'c\');\\n\\ng.nodes.map(x => x.value); // [\'a\', \'b\', \'d\']\\n[...g.edges.values()].map(({ a, b }) => `${a} => ${b}`);\\n// [\'d => a\']\\n\\ng.setEdgeWeight(\'d\', \'a\', 5);\\ng.getEdgeWeight(\'d\', \'a\'); // 5
Last updated: August 17, 2021 View on GitHub
","description":"Definition\\n\\nA graph is a data structure consisting of a set of nodes or vertices and a set of edges that represent connections between those nodes. Graphs can be directed or undirected, while their edges can be assigned numeric weights.\\n\\nEach node in a graph data structure must…","guid":"https://www.30secondsofcode.org/js/s/data-structures-graph","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2021-08-16T16:00:00.404Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/purple-flower-macro-1-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/purple-flower-macro-1-800.webp","type":"photo","width":360,"height":180},{"url":"https://www.30secondsofcode.org/assets/illustrations/ds-graph.svg","type":"photo","width":800,"height":316,"blurhash":"L12i67-:Dzxut7ofWBaxakIUD#og"}],"categories":[" JavaScript "," Data Structures "],"attachments":null,"extra":null,"language":null},{"title":"JavaScript Data Structures - Doubly Linked List","url":"https://www.30secondsofcode.org/js/s/data-structures-doubly-linked-list","content":"A doubly linked list is a linear data structure that represents a collection of elements, where each element points both to the next and the previous one. The first element in the doubly linked list is the head and the last element is the tail.
\\nEach element of a doubly linked list data structure must have the following properties:
\\nvalue
: The value of the elementnext
: A pointer to the next element in the linked list (null
if there is none)previous
: A pointer to the previous element in the linked list (null
if there is none)The main properties of a doubly linked list data structure are:
\\nsize
: The number of elements in the doubly linked listhead
: The first element in the doubly linked listtail
: The last element in the doubly linked listThe main operations of a doubly linked list data structure are:
\\ninsertAt
: Inserts an element at the specific indexremoveAt
: Removes the element at the specific indexgetAt
: Retrieves the element at the specific indexclear
: Empties the doubly linked listreverse
: Reverses the order of elements in the doubly linked listclass DoublyLinkedList {\\n constructor() {\\n this.nodes = [];\\n }\\n\\n get size() {\\n return this.nodes.length;\\n }\\n\\n get head() {\\n return this.size ? this.nodes[0] : null;\\n }\\n\\n get tail() {\\n return this.size ? this.nodes[this.size - 1] : null;\\n }\\n\\n insertAt(index, value) {\\n const previousNode = this.nodes[index - 1] || null;\\n const nextNode = this.nodes[index] || null;\\n const node = { value, next: nextNode, previous: previousNode };\\n\\n if (previousNode) previousNode.next = node;\\n if (nextNode) nextNode.previous = node;\\n this.nodes.splice(index, 0, node);\\n }\\n\\n insertFirst(value) {\\n this.insertAt(0, value);\\n }\\n\\n insertLast(value) {\\n this.insertAt(this.size, value);\\n }\\n\\n getAt(index) {\\n return this.nodes[index];\\n }\\n\\n removeAt(index) {\\n const previousNode = this.nodes[index - 1] || null;\\n const nextNode = this.nodes[index + 1] || null;\\n\\n if (previousNode) previousNode.next = nextNode;\\n if (nextNode) nextNode.previous = previousNode;\\n\\n return this.nodes.splice(index, 1);\\n }\\n\\n clear() {\\n this.nodes = [];\\n }\\n\\n reverse() {\\n this.nodes = this.nodes.reduce((acc, { value }) => {\\n const nextNode = acc[0] || null;\\n const node = { value, next: nextNode, previous: null };\\n if (nextNode) nextNode.previous = node;\\n return [node, ...acc];\\n }, []);\\n }\\n\\n *[Symbol.iterator]() {\\n yield* this.nodes;\\n }\\n}\\n
class
with a constructor
that initializes an empty array, nodes
, for each instance.size
getter, that returns that uses Array.prototype.length
to return the number of elements in the nodes
array.head
getter, that returns the first element of the nodes
array or null
if empty.tail
getter, that returns the last element of the nodes
array or null
if empty.insertAt()
method, which uses Array.prototype.splice()
to add a new object in the nodes
array, updating the next
and previous
keys of the previous and next elements respectively.insertFirst()
and insertLast()
that use the insertAt()
method to insert a new element at the start or end of the nodes
array respectively.getAt()
method, which retrieves the element in the given index
.removeAt()
method, which uses Array.prototype.splice()
to remove an object in the nodes
array, updating the next
and previous
keys of the previous and next elements respectively.clear()
method, which empties the nodes
array.reverse()
method, which uses Array.prototype.reduce()
and the spread operator (...
) to reverse the order of the nodes
array, updating the next
and previous
keys of each element appropriately.Symbol.iterator
, which delegates to the nodes
array\'s iterator using the yield*
syntax.const list = new DoublyLinkedList();\\n\\nlist.insertFirst(1);\\nlist.insertFirst(2);\\nlist.insertFirst(3);\\nlist.insertLast(4);\\nlist.insertAt(3, 5);\\n\\nlist.size; // 5\\nlist.head.value; // 3\\nlist.head.next.value; // 2\\nlist.tail.value; // 4\\nlist.tail.previous.value; // 5\\n[...list.map(e => e.value)]; // [3, 2, 1, 5, 4]\\n\\nlist.removeAt(1); // 2\\nlist.getAt(1).value; // 1\\nlist.head.next.value; // 1\\n[...list.map(e => e.value)]; // [3, 1, 5, 4]\\n\\nlist.reverse();\\n[...list.map(e => e.value)]; // [4, 5, 1, 3]\\n\\nlist.clear();\\nlist.size; // 0
Last updated: August 12, 2021 View on GitHub
","description":"Definition\\n\\nA doubly linked list is a linear data structure that represents a collection of elements, where each element points both to the next and the previous one. The first element in the doubly linked list is the head and the last element is the tail.\\n\\nEach element of a…","guid":"https://www.30secondsofcode.org/js/s/data-structures-doubly-linked-list","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2021-08-11T16:00:00.148Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/purple-flower-macro-4-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/purple-flower-macro-4-800.webp","type":"photo","width":360,"height":180},{"url":"https://www.30secondsofcode.org/assets/illustrations/ds-doubly-linked-list.svg","type":"photo","width":800,"height":133,"blurhash":"L54.9|WA4,%NM_j]ogWA4mof%OM{"}],"categories":[" JavaScript "," Data Structures "],"attachments":null,"extra":null,"language":null},{"title":"JavaScript Data Structures - Linked List","url":"https://www.30secondsofcode.org/js/s/data-structures-linked-list","content":"A linked list is a linear data structure that represents a collection of elements, where each element points to the next one. The first element in the linked list is the head and the last element is the tail.
\\nEach element of a linked list data structure must have the following properties:
\\nvalue
: The value of the elementnext
: A pointer to the next element in the linked list (null
if there is none)The main properties of a linked list data structure are:
\\nsize
: The number of elements in the linked listhead
: The first element in the linked listtail
: The last element in the linked listThe main operations of a linked list data structure are:
\\ninsertAt
: Inserts an element at the specific indexremoveAt
: Removes the element at the specific indexgetAt
: Retrieves the element at the specific indexclear
: Empties the linked listreverse
: Reverses the order of elements in the linked listclass LinkedList {\\n constructor() {\\n this.nodes = [];\\n }\\n\\n get size() {\\n return this.nodes.length;\\n }\\n\\n get head() {\\n return this.size ? this.nodes[0] : null;\\n }\\n\\n get tail() {\\n return this.size ? this.nodes[this.size - 1] : null;\\n }\\n\\n insertAt(index, value) {\\n const previousNode = this.nodes[index - 1] || null;\\n const nextNode = this.nodes[index] || null;\\n const node = { value, next: nextNode };\\n\\n if (previousNode) previousNode.next = node;\\n this.nodes.splice(index, 0, node);\\n }\\n\\n insertFirst(value) {\\n this.insertAt(0, value);\\n }\\n\\n insertLast(value) {\\n this.insertAt(this.size, value);\\n }\\n\\n getAt(index) {\\n return this.nodes[index];\\n }\\n\\n removeAt(index) {\\n const previousNode = this.nodes[index - 1];\\n const nextNode = this.nodes[index + 1] || null;\\n\\n if (previousNode) previousNode.next = nextNode;\\n\\n return this.nodes.splice(index, 1);\\n }\\n\\n clear() {\\n this.nodes = [];\\n }\\n\\n reverse() {\\n this.nodes = this.nodes.reduce(\\n (acc, { value }) => [{ value, next: acc[0] || null }, ...acc],\\n []\\n );\\n }\\n\\n *[Symbol.iterator]() {\\n yield* this.nodes;\\n }\\n}\\n
class
with a constructor
that initializes an empty array, nodes
, for each instance.size
getter, that returns that uses Array.prototype.length
to return the number of elements in the nodes
array.head
getter, that returns the first element of the nodes
array or null
if empty.tail
getter, that returns the last element of the nodes
array or null
if empty.insertAt()
method, which uses Array.prototype.splice()
to add a new object in the nodes
array, updating the next
key of the previous element.insertFirst()
and insertLast()
that use the insertAt()
method to insert a new element at the start or end of the nodes
array respectively.getAt()
method, which retrieves the element in the given index
.removeAt()
method, which uses Array.prototype.splice()
to remove an object in the nodes
array, updating the next
key of the previous element.clear()
method, which empties the nodes
array.reverse()
method, which uses Array.prototype.reduce()
and the spread operator (...
) to reverse the order of the nodes
array, updating the next
key of each element appropriately.Symbol.iterator
, which delegates to the nodes
array\'s iterator using the yield*
syntax.const list = new LinkedList();\\n\\nlist.insertFirst(1);\\nlist.insertFirst(2);\\nlist.insertFirst(3);\\nlist.insertLast(4);\\nlist.insertAt(3, 5);\\n\\nlist.size; // 5\\nlist.head.value; // 3\\nlist.head.next.value; // 2\\nlist.tail.value; // 4\\n[...list.map(e => e.value)]; // [3, 2, 1, 5, 4]\\n\\nlist.removeAt(1); // 2\\nlist.getAt(1).value; // 1\\nlist.head.next.value; // 1\\n[...list.map(e => e.value)]; // [3, 1, 5, 4]\\n\\nlist.reverse();\\n[...list.map(e => e.value)]; // [4, 5, 1, 3]\\n\\nlist.clear();\\nlist.size; // 0
Last updated: August 8, 2021 View on GitHub
","description":"Definition\\n\\nA linked list is a linear data structure that represents a collection of elements, where each element points to the next one. The first element in the linked list is the head and the last element is the tail.\\n\\nEach element of a linked list data structure must have the…","guid":"https://www.30secondsofcode.org/js/s/data-structures-linked-list","author":"30 Seconds of Code","authorUrl":null,"authorAvatar":null,"publishedAt":"2021-08-07T16:00:00.694Z","media":[{"url":"https://www.30secondsofcode.orghttps://www.30secondsofcode.org/assets/cover/purple-flower-macro-3-800.webp","type":"photo"},{"url":"https://www.30secondsofcode.org/assets/cover/purple-flower-macro-3-800.webp","type":"photo","width":360,"height":180},{"url":"https://www.30secondsofcode.org/assets/illustrations/ds-linked-list.svg","type":"photo","width":800,"height":133,"blurhash":"L655IRI9M{%NM_j^ofax4mxvt7Ri"}],"categories":[" JavaScript "," Data Structures "],"attachments":null,"extra":null,"language":null}],"readCount":93,"subscriptionCount":1,"analytics":{"feedId":"98941576365355008","updatesPerWeek":0,"subscriptionCount":1,"latestEntryPublishedAt":null,"view":0}}')