Skip to content

Instantly share code, notes, and snippets.

@marcogrcr
Last active April 14, 2025 21:00
Show Gist options
  • Save marcogrcr/02d049606191a38550dbb3db106cbefc to your computer and use it in GitHub Desktop.
Save marcogrcr/02d049606191a38550dbb3db106cbefc to your computer and use it in GitHub Desktop.
TypeScript StringBuilder
export interface StringBuilderOptions {
/**
* The initial size of the underlying buffer in bytes.
* @default 1048576
*/
readonly initialSize?: number;
/**
* The growth factor of the underlying buffer.
* @default 20
*/
readonly growthFactor?: number;
}
/**
* A memory efficient string concatenation builder.
* @example
* const builder = new StringBuilder();
* builder.append("Hello").append(", world!");
*
* // if you need the string
* const str = builder.toString();
*
* // if you don't need the string (more memory efficient)
* await fetch("https://www.exmaple.com", {
* method: "POST",
* headers: { "content-type": "text/plain" },
* body: builder.arrayUnsafe,
* });
*/
export class StringBuilder {
static #decoder = new TextDecoder();
static #encoder = new TextEncoder();
#array: Uint8Array;
#growthFactor: number;
#len = 0;
/** Gets a {@link Uint8Array} view of the underlying buffer with the string. */
get arrayUnsafe(): Uint8Array {
return new Uint8Array(this.#array.buffer, 0, this.#len);
}
/** Gets the current length in bytes of the builder. */
get byteLength(): number {
return this.#len;
}
/** Creates a new instance of the {@link StringBuilder} class. */
constructor(options: StringBuilderOptions = {}) {
const { initialSize = 1048576, growthFactor = 0.2 } = options;
this.#array = new Uint8Array(initialSize);
this.#growthFactor = 1 + growthFactor;
}
/**
* Appends a `string` or a {@link Uint8Array} with a `UTF-8`-encoded string.
* @param value The string to append.
*/
append(value: string | Uint8Array): StringBuilder {
const data =
typeof value === "string" ? StringBuilder.#encoder.encode(value) : value;
this.#checkSize(data);
this.#array.set(data, this.#len);
this.#len += data.byteLength;
return this;
}
/** Clears the builder instance so that it can be re-used. */
clear(): void {
this.#len = 0;
}
/** Returns a {@link Uint8Array} with the string. */
toArray() {
return this.#array.slice(0, this.#len);
}
/** Returns the created string. */
toString() {
return StringBuilder.#decoder.decode(this.#array.subarray(0, this.#len));
}
/**
* Resizes the underlying array if necessary.
* @param data The data to append.
*/
#checkSize(data: Uint8Array) {
if (data.byteLength + this.#len > this.#array.byteLength) {
const array = new Uint8Array(
Math.trunc(
(this.#array.byteLength + data.byteLength) * this.#growthFactor,
),
);
array.set(this.#array);
this.#array = array;
}
}
}
import { describe, expect, it } from "vitest";
import { StringBuilder } from "../../../src/util/string-builder";
describe("StringBuilder", () => {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
describe("constructor", () => {
it("creates underlying buffer with a default size of 1MB", () => {
const actual = new StringBuilder().arrayUnsafe.buffer.byteLength;
expect(actual).toBe(1048576);
});
it("creates underlying buffer with specified size", () => {
const expected = 1;
const actual = new StringBuilder({ initialSize: expected }).arrayUnsafe
.buffer.byteLength;
expect(actual).toBe(expected);
});
});
describe("append", () => {
it("append values correctly", () => {
const sut = new StringBuilder()
.append("Hello")
.append(encoder.encode(" World"))
.append(" ๐Ÿ™ƒ");
const expected = "Hello World ๐Ÿ™ƒ";
expect(sut.byteLength).toBe(encoder.encode(expected).byteLength);
expect(sut.toString()).toBe(expected);
});
describe("resize buffer", () => {
it("adjusts underyling buffer using growth factor", () => {
const sut = new StringBuilder({
initialSize: 1,
growthFactor: 2, // 200%
});
sut.append("H").append("ello World!");
const expected = "Hello World!";
expect(sut.arrayUnsafe.buffer.byteLength).toBe(expected.length * 3);
expect(sut.toString()).toBe(expected);
});
});
});
describe("arrayUnsafe", () => {
it("returns view of underlying buffer", () => {
const sut = new StringBuilder().append("Hello").append(" World!");
sut.arrayUnsafe.set(encoder.encode("What is up!?"));
expect(sut.toString()).toBe("What is up!?");
});
});
describe("clear", () => {
it("clears underlying buffer", () => {
const sut = new StringBuilder().append("Hello").append(" World!");
expect(sut.byteLength).toBe(12);
expect(sut.toString()).toBe("Hello World!");
sut.clear();
expect(sut.byteLength).toBe(0);
expect(sut.toString()).toBe("");
sut.append("Goodbye").append(" World!");
expect(sut.byteLength).toBe(14);
expect(sut.toString()).toBe("Goodbye World!");
});
});
describe("toArray", () => {
it("returns copy of underlying buffer", () => {
const sut = new StringBuilder().append("Hello").append(" World!");
const actual = sut.toArray();
expect(decoder.decode(actual)).toBe("Hello World!");
actual.set(encoder.encode("What is up!?"));
expect(decoder.decode(actual)).toBe("What is up!?");
expect(decoder.decode(sut.arrayUnsafe)).toBe("Hello World!");
});
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment