Skip to content

Commit d191da1

Browse files
committed
feat: add typescript typing
1 parent 728a316 commit d191da1

10 files changed

Lines changed: 150 additions & 10 deletions

File tree

‎.changeset/metal-pandas-wave.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tiny-merge-patch": minor
3+
---
4+
5+
Add TypeScript typings so patch results are inferred accurately.

‎.tool-versions‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
nodejs 22.14.0
2+
pnpm 10.33.0

‎.vscode/settings.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"editor.codeActionsOnSave": {
3+
"source.organizeImports.biome": "explicit",
4+
"source.fixAll.biome": "explicit"
5+
}
6+
}

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ describe modifications to JSON documents.
1212
This library complies with the functional programming style: it does not mutate
1313
the original target, but recycles what it can. It is particularly appropriate to update a state used by [React](https://reactjs.org), allowing selective re-rendering of the UI.
1414

15+
`tiny-merge-patch` is fully typed and provides strong TypeScript inference for patch results.
16+
1517
`tiny-merge-patch` passes all [RFC 7396](http://tools.ietf.org/html/rfc7396) tests.
1618

1719
## Install

‎biome.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,12 @@
1313
"rules": {
1414
"recommended": true
1515
}
16+
},
17+
"assist": {
18+
"actions": {
19+
"source": {
20+
"organizeImports": "on"
21+
}
22+
}
1623
}
1724
}

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@
3636
"version-packages": "changeset version",
3737
"release": "changeset publish",
3838
"prepublishOnly": "pnpm run build",
39-
"test": "vitest run --coverage",
40-
"test:watch": "vitest",
39+
"test": "vitest run --coverage --typecheck",
40+
"test:watch": "vitest --typecheck",
4141
"lint": "biome check .",
4242
"format": "biome format --write .",
4343
"check": "pnpm run lint && pnpm run typecheck && pnpm run test",

‎src/index.ts‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,64 @@
44
const isObject = (val: unknown): val is Record<string, unknown> =>
55
val != null && typeof val === "object" && Array.isArray(val) === false;
66

7+
/**
8+
* Extract keys from a patch object where the value is null (properties to remove).
9+
*/
10+
type NullKeys<T> = {
11+
[K in keyof T]: T[K] extends null ? K : never;
12+
}[keyof T];
13+
14+
type IsPlainObject<T> = T extends readonly unknown[]
15+
? false
16+
: T extends Record<PropertyKey, unknown>
17+
? true
18+
: false;
19+
20+
declare const emptyObjectSymbol: unique symbol;
21+
export interface EmptyObject {
22+
[emptyObjectSymbol]?: never;
23+
}
24+
25+
type NormalizeEmptyObject<T extends object> = keyof T extends never ? EmptyObject : T;
26+
27+
type Patched<Origin, Patch> =
28+
IsPlainObject<Patch> extends true
29+
? IsPlainObject<Origin> extends true
30+
? NormalizeEmptyObject<{
31+
[K in Exclude<keyof Origin | keyof Patch, NullKeys<Patch>>]: K extends keyof Patch
32+
? K extends keyof Origin
33+
? Patched<Origin[K], Patch[K]>
34+
: Patched<unknown, Patch[K]>
35+
: K extends keyof Origin
36+
? Origin[K]
37+
: never;
38+
}>
39+
: NormalizeEmptyObject<{
40+
[K in Exclude<keyof Patch, NullKeys<Patch>>]: Patched<unknown, Patch[K]>;
41+
}>
42+
: Patch;
43+
744
/**
845
* Apply a JSON merge patch. The origin is not modified, but unchanged
946
* properties are recycled.
1047
*/
11-
export function apply(origin: unknown, patch: unknown): unknown {
48+
export function apply<Origin, Patch>(origin: Origin, patch: Patch): Patched<Origin, Patch> {
1249
if (!isObject(patch)) {
1350
// If the patch is not an object, it replaces the origin.
14-
return patch;
51+
return patch as Patched<Origin, Patch>;
1552
}
1653

1754
const result: Record<string, unknown> = !isObject(origin) ? {} : { ...origin };
1855

19-
for (const key of Object.keys(patch)) {
20-
const patchVal = patch[key];
56+
for (const [key, patchVal] of Object.entries(patch)) {
2157
if (patchVal === null) {
2258
delete result[key];
2359
} else {
2460
result[key] = apply(result[key], patchVal);
2561
}
2662
}
2763

28-
return result;
64+
return result as Patched<Origin, Patch>;
2965
}
3066

3167
export default apply;

‎test/index.test-d.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expectTypeOf, it } from "vitest";
2+
import type { EmptyObject } from "../src/index.js";
3+
import jsonMergePatch from "../src/index.js";
4+
5+
describe("jsonMergePatch type", () => {
6+
it("should replace an attribute", () => {
7+
const patched = jsonMergePatch({ a: "b" }, { a: "c" });
8+
expectTypeOf(patched).toEqualTypeOf<{ a: string }>();
9+
});
10+
11+
it("should add an attribute", () => {
12+
const patched = jsonMergePatch({ a: "b" }, { b: "c" });
13+
expectTypeOf(patched).toEqualTypeOf<{ a: string; b: string }>();
14+
});
15+
16+
it("should delete attribute", () => {
17+
let patched = jsonMergePatch({ a: "b" }, { a: null });
18+
expectTypeOf(patched).toEqualTypeOf<EmptyObject>();
19+
// @ts-expect-error
20+
patched = 5;
21+
});
22+
23+
it("should delete attribute without affecting others", () => {
24+
const patched = jsonMergePatch({ a: "b", b: "c" }, { a: null });
25+
expectTypeOf(patched).toEqualTypeOf<{ b: string }>();
26+
});
27+
28+
it("should replace array with a string", () => {
29+
const patched = jsonMergePatch({ a: ["b"] }, { a: "c" });
30+
expectTypeOf(patched).toEqualTypeOf<{ a: string }>();
31+
});
32+
33+
it("should replace a string with an array", () => {
34+
const patched = jsonMergePatch({ a: "c" }, { a: ["b"] });
35+
expectTypeOf(patched).toEqualTypeOf<{ a: string[] }>();
36+
});
37+
38+
it("should apply recursively", () => {
39+
const patched = jsonMergePatch({ a: { b: "c" } }, { a: { b: "d", c: null } });
40+
expectTypeOf(patched).toEqualTypeOf<{ a: { b: string } }>();
41+
});
42+
43+
it("should replace an object array with a number array", () => {
44+
const patched = jsonMergePatch({ a: [{ b: "c" }] }, { a: [1] as const });
45+
expectTypeOf(patched).toEqualTypeOf<{ a: readonly [1] }>();
46+
});
47+
48+
it("should replace an array", () => {
49+
const patched = jsonMergePatch(["a", "b"] as const, ["c", "d"] as const);
50+
expectTypeOf(patched).toEqualTypeOf<readonly ["c", "d"]>();
51+
});
52+
53+
it("should replace an object with an array", () => {
54+
const patched = jsonMergePatch({ a: "b" }, ["c"] as const);
55+
expectTypeOf(patched).toEqualTypeOf<readonly ["c"]>();
56+
});
57+
58+
it("should replace an object with null", () => {
59+
const patched = jsonMergePatch({ a: "foo" }, null);
60+
expectTypeOf(patched).toEqualTypeOf<null>();
61+
});
62+
63+
it("should replace an object with a string", () => {
64+
const patched = jsonMergePatch({ a: "foo" }, "bar");
65+
expectTypeOf(patched).toEqualTypeOf<string>();
66+
});
67+
68+
it("should not change null attributes", () => {
69+
const patched = jsonMergePatch({ e: null }, { a: 1 });
70+
expectTypeOf(patched).toEqualTypeOf<{ e: null; a: number }>();
71+
});
72+
73+
it("should not set an attribute to null", () => {
74+
const patched = jsonMergePatch([1, 2], { a: "b", c: null });
75+
expectTypeOf(patched).toEqualTypeOf<{ a: string }>();
76+
});
77+
78+
it("should not set an attribute to null in a sub object", () => {
79+
const patched = jsonMergePatch({}, { a: { bb: { ccc: null } } });
80+
expectTypeOf(patched).toEqualTypeOf<{ a: { bb: EmptyObject } }>();
81+
});
82+
});

‎test/index.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import jsonMergePatch from "../src";
2+
import jsonMergePatch from "../src/index.js";
33

44
describe("jsonMergePatch", () => {
55
it("should replace an attribute", () => {

‎tsconfig.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"compilerOptions": {
33
"target": "ES2022",
4-
"module": "ESNext",
5-
"moduleResolution": "Bundler",
4+
"module": "nodenext",
5+
"moduleResolution": "nodenext",
66
"strict": true,
77
"skipLibCheck": true,
88
"forceConsistentCasingInFileNames": true,

0 commit comments

Comments
 (0)