Functions are the building blocks of software. They encapsulate reusable pieces of logic that can be applied to various inputs and allow programmers to reason at progressively higher levels of abstraction. Software is essentially a large tree of functions calling other functions.
Let's walk through the process of writing a function in JavaScript [1]. Our function will escape the special characters &
, <
, >
, '
, and "
in a string, so that it can be inserted safely into an HTML page without interfering with the markup or causing XSS attacks.
Let's call our function escapeForHtml
, and let's call its sole argument unsafeText
:
function escapeForHtml(unsafeText) {
// function body goes here
}
Names are important! For instance, escapeHtml
would be a misleading name as it suggests the function expects HTML as input. Similarly, argument names like input
or content
convey far less information than unsafeText
regarding the type and nature of the input.
Most functions should have just one or two arguments. If a function requires more than two arguments e.g. a set of options, you can pass an object with several keys as the sole (or second) argument to the function e.g. escapeForHtml(unsafeText, options = {})
.
Let's implement our function by searching for special characters in unsafeText
using a regular expression, and replacing them with the corresponding HTML entities:
function escapeForHtml(unsafeText) {
return unsafeText.replace(/[&<>"']/g, (match) => {
switch (match) {
case "&": return "&";
case "<": return "<";
case ">": return ">";
case '"': return """;
case "'": return "'";
default: return match;
}
});
}
A function should do just one thing i.e. it should implement one idea or thought. As a result, most functions you write should be fairly short and easy to understand. If the body of a function exceeds 25-30 lines of code, consider splitting it into multiple functions.
Let's ensure that our function accepts and returns a string with a couple of assertions [2]:
import { assert } from "jsr:@std/assert";
function escapeForHtml(unsafeText) {
// Assert argument(s)
assert(typeof unsafeText === "string", "`unsafeText` must be a string");
const safeText = unsafeText.replace(/[&<>"']/g, (match) => {
// same as above
});
// Assert return value
assert(typeof safeText === "string", "`safeText` must be a string");
return safeText;
}
Assertions are a powerful but rarely used tool for catching programmer errors early and detecting subtle bugs. Input assertions prevent a function from being used incorrectly, and output assertions prevent a function from returning an invalid result (perhaps due to a bug).
Assertions can be used to perform various kinds of validation. For instance, we could assert that unsafeText
is not an empty string, or that safeText
does not contain any disallowed characters. Use assertions for type validation, documentation, and runtime checks [3].
Let's add some documentation and types for our function using JSDoc comments [4]:
/**
* Escapes special characters in a string for safe usage in HTML.
* Converts &, <, >, ", and ' to their respective HTML entities.
* Helps prevent XSS (Cross-Site Scripting) attacks when inserting text.
*
* @param {string} unsafeText - The string to be escaped
* @returns {string} The escaped string safe for use in HTML
*/
function escapeForHtml(unsafeText) {
// same as above
}
Good documentation explains the what, how, and why of the function. It also briefly describes the inputs and output. Editors like VS Code parse JSDoc comments to provide inline documentation, type checking, and automatic code completion at call sites.
Sometimes a single line of documentation is sufficient, especially for small utility functions that are only used locally i.e. within the same file or module. Here's an example:
/** Escape special characters for safe usage in HTML. */
function escapeForHtml(unsafeText) {
// same as above
}
Finally, let's add some unit tests to verify that our function works as expected [5]:
import { assertEquals, assertThrows } from "jsr:@std/assert";
import { escapeForHtml } from "./utils.js";
Deno.test("escapeForHtml() escapes &, <, >, \", and '", () => {
const input = 'Tom & Jerry\'s <"quotes">';
const escapedOutput = "Tom & Jerry's <"quotes">";
assertEquals(escapeForHtml(input), escapedOutput);
});
Deno.test("escapeForHtml() escapes XSS attack vectors", () => {
const xssInput = `<script>alert('XSS')</script>`;
const escapedOutput = `<script>alert('XSS')</script>`;
assertEquals(escapeForHtml(xssInput), escapedOutput);
});
Deno.test("escapeForHtml() throws error for non-string input", () => {
const message = "`unsafeString` must be a string";
assertThrows(() => escapeForHtml(123), Error, message);
assertThrows(() => escapeForHtml(null), Error, message);
assertThrows(() => escapeForHtml(undefined), Error, message);
assertThrows(() => escapeForHtml({}), Error, message);
});
Give each test a meaningful title, make sure to test every branch, try to model real-world usage, and include tests for errors and failures. Apart from testing functionality, unit tests serve as documentation and provide guardrails for modifying or refactoring the function.
Keep the following principles in mind while writing a function:
Pick good names for the function and its arguments to indicate what it does.
Keep the implementation short (under 25-30 lines) and easy to understand.
Add input and output assertions to detect illegal usage and programmer errors.
Document the what, how & why of the function and surface it via the code editor.
Write comprehensive unit tests to ensure the function works as expected.
Most software is too large and complex for one person to comprehend or reason about. However, most functions can be small, simple, and easy to hold in one's head. Writing good functions is the key to building high-quality, robust, and bug-free software.
I intend to put these principles into practice as I develop SwiftAce, as these small details often add up to make a big difference in final product. I hope you found this post helpful.
The code in this post is written to be executed with the Deno JavaScript runtime.
Deno automatically download modules from JSR for imports with a jsr:
specifier.
Check out Tiger Style to learn more about the effective use of assertions.
I prefer JSDoc over TypeScript as it's opt-in and doesn't block code execution.
You can also use Mocha or Jest for testing instead of Deno's built-in test runner.