Skip to content

Commit dfc3afb

Browse files
authored
Merge pull request #7 from script-development/fs-helpers
feat: add @script-development/fs-helpers package
2 parents f1b10c9 + faaef77 commit dfc3afb

12 files changed

Lines changed: 292 additions & 0 deletions

package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/helpers/package.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "@script-development/fs-helpers",
3+
"version": "0.1.0",
4+
"description": "Tree-shakeable shared utility helpers: deep copy, type guards, and case conversion",
5+
"license": "UNLICENSED",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/script-development/fs-packages.git",
9+
"directory": "packages/helpers"
10+
},
11+
"files": [
12+
"dist"
13+
],
14+
"type": "module",
15+
"main": "./dist/index.cjs",
16+
"module": "./dist/index.mjs",
17+
"types": "./dist/index.d.mts",
18+
"exports": {
19+
".": {
20+
"import": {
21+
"types": "./dist/index.d.mts",
22+
"default": "./dist/index.mjs"
23+
},
24+
"require": {
25+
"types": "./dist/index.d.cts",
26+
"default": "./dist/index.cjs"
27+
}
28+
}
29+
},
30+
"publishConfig": {
31+
"access": "public",
32+
"registry": "https://registry.npmjs.org"
33+
},
34+
"scripts": {
35+
"build": "tsdown",
36+
"typecheck": "tsc --noEmit",
37+
"lint:pkg": "publint && attw --pack",
38+
"test": "vitest run",
39+
"test:coverage": "vitest run --coverage"
40+
},
41+
"dependencies": {
42+
"string-ts": "^2.3.1"
43+
}
44+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { DeepSnakeKeys } from "string-ts";
2+
3+
import { deepCamelKeys, deepSnakeKeys } from "string-ts";
4+
5+
export { deepCamelKeys, deepSnakeKeys };
6+
7+
/**
8+
* Convert a snake_case API response to the camelCase generic T.
9+
*
10+
* Runtime transformation via `deepCamelKeys` aligns keys with T's shape;
11+
* the cast is safe because the transformation is exhaustive.
12+
*/
13+
export const toCamelCaseTyped = <T extends object>(data: T | DeepSnakeKeys<T>): T =>
14+
deepCamelKeys(data) as unknown as T;

packages/helpers/src/deep-copy.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
type WritablePrimitive = undefined | null | boolean | string | number | Date;
2+
3+
export type Writable<T> = T extends WritablePrimitive
4+
? T
5+
: T extends readonly [...infer U]
6+
? { -readonly [K in keyof U]: Writable<U[K]> }
7+
: T extends ReadonlyArray<infer U>
8+
? Array<Writable<U>>
9+
: T extends object
10+
? { -readonly [K in keyof T]: Writable<T[K]> }
11+
: T;
12+
13+
/**
14+
* Deep copy for plain objects, arrays, and Date instances.
15+
*
16+
* Uses manual recursion over structuredClone for performance (~10x faster
17+
* depending on object size and depth).
18+
*
19+
* Handles: primitives, plain objects, arrays, Date, null.
20+
* Does NOT handle: Map, Set, RegExp, functions, circular references.
21+
*/
22+
export const deepCopy = <T>(toCopy: T): Writable<T> => {
23+
if (typeof toCopy !== "object" || toCopy === null) return toCopy as Writable<T>;
24+
25+
if (toCopy instanceof Date) return new Date(toCopy.getTime()) as Writable<T>;
26+
27+
if (Array.isArray(toCopy)) return toCopy.map((value: unknown) => deepCopy(value)) as Writable<T>;
28+
29+
const copiedObject: Record<string, unknown> = {};
30+
31+
for (const key of Object.keys(toCopy)) {
32+
copiedObject[key] = deepCopy((toCopy as Record<string, unknown>)[key]);
33+
}
34+
35+
return copiedObject as Writable<T>;
36+
};

packages/helpers/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { deepCopy } from "./deep-copy";
2+
export type { Writable } from "./deep-copy";
3+
4+
export { isExisting } from "./type-guards";
5+
6+
export { toCamelCaseTyped, deepCamelKeys, deepSnakeKeys } from "./case-conversion";
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Type guard that checks if an object has an `id` property,
3+
* distinguishing existing resources from new ones.
4+
*/
5+
export const isExisting = <T extends { id: number }>(obj: T | Omit<T, "id">): obj is T =>
6+
"id" in obj;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { toCamelCaseTyped, deepCamelKeys, deepSnakeKeys } from "../src";
2+
import { describe, expect, it } from "vitest";
3+
4+
describe("toCamelCaseTyped", () => {
5+
it("should convert snake_case keys to camelCase", () => {
6+
const input = { first_name: "John", last_name: "Doe" };
7+
8+
const result = toCamelCaseTyped<{ firstName: string; lastName: string }>(input);
9+
10+
expect(result).toEqual({ firstName: "John", lastName: "Doe" });
11+
});
12+
13+
it("should handle nested objects", () => {
14+
const input = { user_data: { first_name: "John" } };
15+
16+
const result = toCamelCaseTyped<{ userData: { firstName: string } }>(input);
17+
18+
expect(result).toEqual({ userData: { firstName: "John" } });
19+
});
20+
21+
it("should handle already camelCase data", () => {
22+
const input = { firstName: "John" };
23+
24+
const result = toCamelCaseTyped<{ firstName: string }>(input);
25+
26+
expect(result).toEqual({ firstName: "John" });
27+
});
28+
});
29+
30+
describe("re-exports", () => {
31+
it("should re-export deepCamelKeys from string-ts", () => {
32+
expect(typeof deepCamelKeys).toBe("function");
33+
expect(deepCamelKeys({ snake_case: 1 })).toEqual({ snakeCase: 1 });
34+
});
35+
36+
it("should re-export deepSnakeKeys from string-ts", () => {
37+
expect(typeof deepSnakeKeys).toBe("function");
38+
expect(deepSnakeKeys({ camelCase: 1 })).toEqual({ camel_case: 1 });
39+
});
40+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { deepCopy } from "../src";
2+
import { describe, expect, it } from "vitest";
3+
4+
describe("deepCopy", () => {
5+
it("should return primitives as-is", () => {
6+
expect(deepCopy(42)).toBe(42);
7+
expect(deepCopy("hello")).toBe("hello");
8+
expect(deepCopy(true)).toBe(true);
9+
expect(deepCopy(null)).toBeNull();
10+
expect(deepCopy(undefined)).toBeUndefined();
11+
});
12+
13+
it("should deep copy plain objects", () => {
14+
const original = { a: 1, b: { c: 2 } };
15+
16+
const copy = deepCopy(original);
17+
18+
expect(copy).toEqual(original);
19+
expect(copy).not.toBe(original);
20+
expect(copy.b).not.toBe(original.b);
21+
});
22+
23+
it("should deep copy arrays", () => {
24+
const original = [1, [2, 3], { a: 4 }];
25+
26+
const copy = deepCopy(original);
27+
28+
expect(copy).toEqual(original);
29+
expect(copy).not.toBe(original);
30+
expect(copy[1]).not.toBe(original[1]);
31+
expect(copy[2]).not.toBe(original[2]);
32+
});
33+
34+
it("should deep copy Date instances", () => {
35+
const original = new Date("2026-01-01");
36+
37+
const copy = deepCopy(original);
38+
39+
expect(copy).toEqual(original);
40+
expect(copy).not.toBe(original);
41+
expect(copy.getTime()).toBe(original.getTime());
42+
});
43+
44+
it("should deep copy nested objects with arrays", () => {
45+
const original = { items: [{ id: 1, name: "test" }], count: 1 };
46+
47+
const copy = deepCopy(original);
48+
49+
expect(copy).toEqual(original);
50+
expect(copy.items).not.toBe(original.items);
51+
expect(copy.items[0]).not.toBe(original.items[0]);
52+
});
53+
54+
it("should handle empty objects", () => {
55+
expect(deepCopy({})).toEqual({});
56+
});
57+
58+
it("should handle empty arrays", () => {
59+
expect(deepCopy([])).toEqual([]);
60+
});
61+
62+
it("should produce a mutable copy from readonly input", () => {
63+
const original = { a: 1, b: { c: 2 } } as const;
64+
65+
const copy = deepCopy(original);
66+
copy.a = 99;
67+
copy.b.c = 99;
68+
69+
expect(copy.a).toBe(99);
70+
expect(copy.b.c).toBe(99);
71+
expect(original.a).toBe(1);
72+
});
73+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { isExisting } from "../src";
2+
import { describe, expect, it } from "vitest";
3+
4+
describe("isExisting", () => {
5+
it("should return true for objects with an id", () => {
6+
expect(isExisting({ id: 1, name: "test" })).toBe(true);
7+
});
8+
9+
it("should return false for objects without an id", () => {
10+
expect(isExisting({ name: "test" })).toBe(false);
11+
});
12+
13+
it("should narrow the type correctly", () => {
14+
const item: { id: number; name: string } | { name: string } = { id: 1, name: "test" };
15+
16+
if (isExisting(item)) {
17+
// TypeScript should narrow to the type with id
18+
expect(item.id).toBe(1);
19+
}
20+
});
21+
});

packages/helpers/tsconfig.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"outDir": "dist",
5+
"rootDir": "src"
6+
},
7+
"include": ["src"]
8+
}

0 commit comments

Comments
 (0)