Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions packages/helpers/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@script-development/fs-helpers",
"version": "0.1.0",
"description": "Tree-shakeable shared utility helpers: deep copy, type guards, and case conversion",
"license": "UNLICENSED",
"repository": {
"type": "git",
"url": "https://github.com/script-development/fs-packages.git",
"directory": "packages/helpers"
},
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
},
"scripts": {
"build": "tsdown",
"typecheck": "tsc --noEmit",
"lint:pkg": "publint && attw --pack",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"string-ts": "^2.3.1"
}
}
14 changes: 14 additions & 0 deletions packages/helpers/src/case-conversion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { DeepSnakeKeys } from "string-ts";

import { deepCamelKeys, deepSnakeKeys } from "string-ts";

export { deepCamelKeys, deepSnakeKeys };

/**
* Convert a snake_case API response to the camelCase generic T.
*
* Runtime transformation via `deepCamelKeys` aligns keys with T's shape;
* the cast is safe because the transformation is exhaustive.
*/
export const toCamelCaseTyped = <T extends object>(data: T | DeepSnakeKeys<T>): T =>
deepCamelKeys(data) as unknown as T;
36 changes: 36 additions & 0 deletions packages/helpers/src/deep-copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
type WritablePrimitive = undefined | null | boolean | string | number | Date;

export type Writable<T> = T extends WritablePrimitive
? T
: T extends readonly [...infer U]
? { -readonly [K in keyof U]: Writable<U[K]> }
: T extends ReadonlyArray<infer U>
? Array<Writable<U>>
: T extends object
? { -readonly [K in keyof T]: Writable<T[K]> }
: T;

/**
* Deep copy for plain objects, arrays, and Date instances.
*
* Uses manual recursion over structuredClone for performance (~10x faster
* depending on object size and depth).
*
* Handles: primitives, plain objects, arrays, Date, null.
* Does NOT handle: Map, Set, RegExp, functions, circular references.
*/
export const deepCopy = <T>(toCopy: T): Writable<T> => {
if (typeof toCopy !== "object" || toCopy === null) return toCopy as Writable<T>;

if (toCopy instanceof Date) return new Date(toCopy.getTime()) as Writable<T>;

if (Array.isArray(toCopy)) return toCopy.map((value: unknown) => deepCopy(value)) as Writable<T>;

const copiedObject: Record<string, unknown> = {};

for (const key of Object.keys(toCopy)) {
copiedObject[key] = deepCopy((toCopy as Record<string, unknown>)[key]);
}

return copiedObject as Writable<T>;
};
6 changes: 6 additions & 0 deletions packages/helpers/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { deepCopy } from "./deep-copy";
export type { Writable } from "./deep-copy";

export { isExisting } from "./type-guards";

export { toCamelCaseTyped, deepCamelKeys, deepSnakeKeys } from "./case-conversion";
6 changes: 6 additions & 0 deletions packages/helpers/src/type-guards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Type guard that checks if an object has an `id` property,
* distinguishing existing resources from new ones.
*/
export const isExisting = <T extends { id: number }>(obj: T | Omit<T, "id">): obj is T =>
"id" in obj;
40 changes: 40 additions & 0 deletions packages/helpers/tests/case-conversion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { toCamelCaseTyped, deepCamelKeys, deepSnakeKeys } from "../src";
import { describe, expect, it } from "vitest";

describe("toCamelCaseTyped", () => {
it("should convert snake_case keys to camelCase", () => {
const input = { first_name: "John", last_name: "Doe" };

const result = toCamelCaseTyped<{ firstName: string; lastName: string }>(input);

expect(result).toEqual({ firstName: "John", lastName: "Doe" });
});

it("should handle nested objects", () => {
const input = { user_data: { first_name: "John" } };

const result = toCamelCaseTyped<{ userData: { firstName: string } }>(input);

expect(result).toEqual({ userData: { firstName: "John" } });
});

it("should handle already camelCase data", () => {
const input = { firstName: "John" };

const result = toCamelCaseTyped<{ firstName: string }>(input);

expect(result).toEqual({ firstName: "John" });
});
});

describe("re-exports", () => {
it("should re-export deepCamelKeys from string-ts", () => {
expect(typeof deepCamelKeys).toBe("function");
expect(deepCamelKeys({ snake_case: 1 })).toEqual({ snakeCase: 1 });
});

it("should re-export deepSnakeKeys from string-ts", () => {
expect(typeof deepSnakeKeys).toBe("function");
expect(deepSnakeKeys({ camelCase: 1 })).toEqual({ camel_case: 1 });
});
});
73 changes: 73 additions & 0 deletions packages/helpers/tests/deep-copy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { deepCopy } from "../src";
import { describe, expect, it } from "vitest";

describe("deepCopy", () => {
it("should return primitives as-is", () => {
expect(deepCopy(42)).toBe(42);
expect(deepCopy("hello")).toBe("hello");
expect(deepCopy(true)).toBe(true);
expect(deepCopy(null)).toBeNull();
expect(deepCopy(undefined)).toBeUndefined();
});

it("should deep copy plain objects", () => {
const original = { a: 1, b: { c: 2 } };

const copy = deepCopy(original);

expect(copy).toEqual(original);
expect(copy).not.toBe(original);
expect(copy.b).not.toBe(original.b);
});

it("should deep copy arrays", () => {
const original = [1, [2, 3], { a: 4 }];

const copy = deepCopy(original);

expect(copy).toEqual(original);
expect(copy).not.toBe(original);
expect(copy[1]).not.toBe(original[1]);
expect(copy[2]).not.toBe(original[2]);
});

it("should deep copy Date instances", () => {
const original = new Date("2026-01-01");

const copy = deepCopy(original);

expect(copy).toEqual(original);
expect(copy).not.toBe(original);
expect(copy.getTime()).toBe(original.getTime());
});

it("should deep copy nested objects with arrays", () => {
const original = { items: [{ id: 1, name: "test" }], count: 1 };

const copy = deepCopy(original);

expect(copy).toEqual(original);
expect(copy.items).not.toBe(original.items);
expect(copy.items[0]).not.toBe(original.items[0]);
});

it("should handle empty objects", () => {
expect(deepCopy({})).toEqual({});
});

it("should handle empty arrays", () => {
expect(deepCopy([])).toEqual([]);
});

it("should produce a mutable copy from readonly input", () => {
const original = { a: 1, b: { c: 2 } } as const;

const copy = deepCopy(original);
copy.a = 99;
copy.b.c = 99;

expect(copy.a).toBe(99);
expect(copy.b.c).toBe(99);
expect(original.a).toBe(1);
});
});
21 changes: 21 additions & 0 deletions packages/helpers/tests/type-guards.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { isExisting } from "../src";
import { describe, expect, it } from "vitest";

describe("isExisting", () => {
it("should return true for objects with an id", () => {
expect(isExisting({ id: 1, name: "test" })).toBe(true);
});

it("should return false for objects without an id", () => {
expect(isExisting({ name: "test" })).toBe(false);
});

it("should narrow the type correctly", () => {
const item: { id: number; name: string } | { name: string } = { id: 1, name: "test" };

if (isExisting(item)) {
// TypeScript should narrow to the type with id
expect(item.id).toBe(1);
}
});
});
8 changes: 8 additions & 0 deletions packages/helpers/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
9 changes: 9 additions & 0 deletions packages/helpers/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "tsdown";

export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: true,
});
17 changes: 17 additions & 0 deletions packages/helpers/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
name: "helpers",
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
thresholds: {
lines: 100,
branches: 100,
functions: 100,
statements: 100,
},
},
},
});