Superdiff provides a rich and readable diff for arrays, objects, code, text and coordinates. It supports stream and file inputs for handling large datasets efficiently, is battle-tested, has zero dependencies, and offers top-tier performance.
See the code
Superdiff provides a rich and readable diff for arrays, objects, code, text and coordinates. It supports stream and file inputs for handling large datasets efficiently, is battle-tested, has zero dependencies, and offers top-tier performance.
ℹ️ The documentation is also available on our website!
Superdiff exports 6 functions:

Comparing millions of entries in real time with streamListDiff

Detecting text changes with getTextDiff

Tracking coordinate changes with getGeoDiff

Diffing deeply nested objects with getObjectDiff

Track code changes with getCodeDiff
| Feature | superdiff | deep-object-diff | deep-diff | diff | microdiff |
|---|---|---|---|---|---|
| Object diff | ✅ | ✅ | ✅ | ❌ | ✅ |
| List diff | ✅ | ❌ | ⚠️ | ❌ | ⚠️ |
| Text diff | ✅ | ❌ | ✅ | ✅ | ❌ |
| Coordinates diff | ✅ | ❌ | ❌ | ❌ | ❌ |
| Code diff | ✅ | ❌ | ❌ | ✅ | ❌ |
| Streaming for huge datasets | ✅ | ❌ | ❌ | ❌ | ❌ |
| Move detection | ✅ | ❌ | ❌ | ❌ | ❌ |
| Output refinement | ✅ | ❌ | ❌ | ❌ | ❌ |
| Zero dependencies | ✅ | ✅ | ❌ | ✅ | ✅ |
Some libraries perform array diffing using index-by-index comparison. This approach cannot reliably detect insertions, deletions, or moves correctly. In those cases, support is marked as partial.
Environment: Node.js 24.12.0 (LTS) • MacBook Pro M2 (2023, Sequoia 15.1) • 16GB RAM.
Method: Warm up runs, then each script is executed 20 times, and we keep the median time. To minimize garbage collection and cross‑benchmark interference, all scenarios are run individually. All benchmark scripts are included so you can reproduce the results locally.
| Scenario | superdiff | arr-diff | deep-diff |
|---|---|---|---|
| 10k items array | 1.84 ms | 32.95 ms | 4.74 ms |
| 100k items array | 17.43 ms | 3363.15 ms | 50.36 ms |
| Scenario | superdiff | deep-object-diff | deep-diff | microdiff |
|---|---|---|---|---|
| 10k flat object keys | 2.27 ms | 2.44 ms | 39.37 ms | 2.24 ms |
| 100k flat object keys | 29.23 ms | 31.86 ms | 3784.50 ms | 29.51 ms |
| 100k nested nodes | 4.25 ms | 9.67 ms | 16.51 ms | 7.26 ms |
| Scenario | superdiff | diff |
|---|---|---|
| 10k words | 1.32 ms | 3.86 ms |
| 100k words | 12.09 ms | 45.64 ms |
| 10k sentences | 1.58 ms | 4.53 ms |
| 100k sentences | 22.31 ms | 55.60 ms |
(Superdiff uses its normal accuracy settings to match diff's behavior)
| Scenario | superdiff | diff |
|---|---|---|
| 1k lines | 0.36 ms | 1.14 ms |
| 10k lines | 9.55 ms | 67.21 ms |
| 100k lines | 911.80 ms | 6995.84 ms |
(diff has no line + token API, so both passes are run; its line pass alone is 66 ms at 10k lines)
👉 Despite providing a full structural diff with a richer output, Superdiff consistently matches or outperforms the fastest alternatives tested. It also scales linearly, even with deeply nested data.
I am grateful to the generous donors of Superdiff!
If you or your company uses this library, please show your support by becoming a sponsor! Your name and company logo will be displayed on the README.md. Premium support is also available.
import { getObjectDiff } from "@donedeal0/superdiff";
Compares two objects and returns a diff for each value and its possible subvalues. Supports deeply nested objects of any value type.
Input
prevData: Record<string, unknown>;
nextData: Record<string, unknown>;
options?: {
ignoreArrayOrder?: boolean, // false by default,
showOnly?: {
statuses: ("added" | "deleted" | "updated" | "equal")[], // [] by default
granularity?: "basic" | "deep" // "basic" by default
}
}
prevData: the original object.nextData: the new object.options
ignoreArrayOrder: if true, ["hello", "world"] and ["world", "hello"] are considered equal, because the two arrays contain the same values, just in a different order.
showOnly: returns only the values whose status you are interested in. It takes two parameters:
statuses: status you want to see in the output (e.g. ["added", "equal"])
granularity:
basic returns only the main keys whose status matches your query.deep can return main keys if some of their nested keys' status match your request. The nested keys are filtered accordingly.Output
type ObjectDiff = {
type: "object";
status: "added" | "deleted" | "equal" | "updated";
diff: Diff[];
};
type Diff = {
key: string;
value: unknown;
previousValue: unknown;
status: "added" | "deleted" | "equal" | "updated";
// recursive diff in case of nested keys
diff?: Diff[];
};
Input
getObjectDiff(
{
id: 54,
user: {
name: "joe",
- member: true,
- hobbies: ["golf", "football"],
age: 66,
},
},
{
id: 54,
user: {
name: "joe",
+ member: false,
+ hobbies: ["golf", "chess"],
age: 66,
},
}
);
Output
{
type: "object",
+ status: "updated",
diff: [
{
key: "id",
value: 54,
previousValue: 54,
status: "equal",
},
{
key: "user",
value: {
name: "joe",
member: false,
hobbies: ["golf", "chess"],
age: 66,
},
previousValue: {
name: "joe",
member: true,
hobbies: ["golf", "football"],
age: 66,
},
+ status: "updated",
diff: [
{
key: "name",
value: "joe",
previousValue: "joe",
status: "equal",
},
+ {
+ key: "member",
+ value: false,
+ previousValue: true,
+ status: "updated",
+ },
+ {
+ key: "hobbies",
+ value: ["golf", "chess"],
+ previousValue: ["golf", "football"],
+ status: "updated",
+ },
{
key: "age",
value: 66,
previousValue: 66,
status: "equal",
},
],
},
],
}
import { getListDiff } from "@donedeal0/superdiff";
Compares two arrays and returns a diff for each entry. Supports duplicate values, primitive values and objects.
Input
prevList: T[];
nextList: T[];
options?: {
showOnly?: ("added" | "deleted" | "moved" | "updated" | "equal")[], // [] by default
referenceKey?: string, // "" by default
ignoreArrayOrder?: boolean, // false by default,
considerMoveAsUpdate?: boolean // false by default
}
prevList: the original list.nextList: the new list.options
showOnly gives you the option to return only the values whose status you are interested in (e.g. ["added", "equal"]).referenceKey will consider an object to be updated rather than added or deleted if one of its keys remains stable, such as its id. This option has no effect on other datatypes.ignoreArrayOrder: if true, ["hello", "world"] and ["world", "hello"] are considered equal, because the two arrays contain the same values, just in a different order.considerMoveAsUpdate: if true, a moved value is considered updated.Output
type ListDiff = {
type: "list";
status: "added" | "deleted" | "equal" | "moved" | "updated";
diff: {
value: unknown;
index: number | null;
previousIndex: number | null;
status: "added" | "deleted" | "equal" | "moved" | "updated";
}[];
};
Input
getListDiff(
- ["mbappe", "mendes", "verratti", "ruiz"],
+ ["mbappe", "messi", "ruiz"]
);
Output
{
type: "list",
+ status: "updated",
diff: [
{
value: "mbappe",
index: 0,
previousIndex: 0,
status: "equal",
},
- {
- value: "mendes",
- index: null,
- previousIndex: 1,
- status: "deleted",
- },
- {
- value: "verratti",
- index: null,
- previousIndex: 2,
- status: "deleted",
- },
+ {
+ value: "messi",
+ index: 1,
+ previousIndex: null,
+ status: "added",
+ },
+ {
+ value: "ruiz",
+ index: 2,
+ previousIndex: 3,
+ status: "moved",
},
],
}
// If you are in a server environment
import { streamListDiff } from "@donedeal0/superdiff/server";
// If you are in a browser environment
import { streamListDiff } from "@donedeal0/superdiff/client";
Streams the diff of two object lists, ideal for large lists and maximum performance.
ℹ️ streamListDiff requires ESM support for browser usage. It will work out of the box if you use a modern bundler (Webpack, Rollup) or JavaScript framework (Next.js, Vue.js).
Input
In a server environment,
Readablerefers to Node.js streams, andFilePathrefers to the path of a file (e.g.,./list.json). Examples are provided in the #usage section below.
prevList: Readable | FilePath | Record<string, unknown>[],
nextList: Readable | FilePath | Record<string, unknown>[],
referenceKey: keyof Record<string, unknown>,
options: {
showOnly?: ("added" | "deleted" | "moved" | "updated" | "equal")[], // [] by default
chunksSize?: number, // 0 by default
considerMoveAsUpdate?: boolean; // false by default
useWorker?: boolean; // true by default
showWarnings?: boolean; // true by default
}
In a browser environment,
ReadableStreamrefers to the browser's streaming API, andFilerefers to an uploaded or local file. Examples are provided in the #usage section below.
prevList: ReadableStream<Record<string, unknown>> | File | Record<string, unknown>[],
nextList: ReadableStream<Record<string, unknown>> | File | Record<string, unknown>[],
referenceKey: keyof Record<string, unknown>,
options: {
showOnly?: ("added" | "deleted" | "moved" | "updated" | "equal")[], // [] by default
chunksSize?: number, // 0 by default
considerMoveAsUpdate?: boolean; // false by default
useWorker?: boolean; // true by default
showWarnings?: boolean; // true by default
}
prevList: the original object list.nextList: the new object list.referenceKey: a key common to all objects in your lists (e.g. id).options
chunksSize the number of object diffs returned by each streamed chunk. (e.g. 0 = 1 object diff per chunk, 10 = 10 object diffs per chunk).showOnly gives you the option to return only the values whose status you are interested in (e.g. ["added", "equal"]).considerMoveAsUpdate: if true a moved value is considered updated.useWorker: if true, the diff will be run in a worker for maximum performance. Only recommended for large lists (e.g. +100,000 items).showWarnings: if true, potential warnings will be displayed in the console.⚠️ Warning: using Readable streams may impact workers' performance since they need to be converted to arrays. Consider using arrays or files for optimal performance. Alternatively, you can turn the
useWorkeroption off.
Output
The objects diff are grouped into arrays - called chunks - and are consumed thanks to an event listener. You have access to 3 events:
data: to be notified when a new chunk of object diffs is available.finish: to be notified when the stream is finished.error: to be notified if an error occurs during the stream.interface StreamListener<T> {
on(event: "data", listener: (chunk: StreamListDiff<T>[]) => void);
on(event: "finish", listener: () => void);
on(event: "error", listener: (error: Error) => void);
}
type StreamListDiff<T extends Record<string, unknown>> = {
value: T | null;
index: number | null;
previousValue: T | null;
previousIndex: number | null;
status: "added" | "deleted" | "moved" | "updated" | "equal";
};
Input
You can send streams, file paths, or arrays as input:
If you are in a server environment
// for a simple array
const stream = [{ id: 1, name: "hello" }]
// for a large array
const stream = Readable.from(list, { objectMode: true });
// for a local file
const stream = path.resolve(__dirname, "./list.json");
If you are in a browser environment
// for a simple array
const stream = [{ id: 1, name: "hello" }]
// for a large array
const stream = new ReadableStream({
start(controller) {
list.forEach((value) => controller.enqueue(value));
controller.close();
},
});
// for a local file
const stream = new File([JSON.stringify(file)], "file.json", { type: "application/json" });
// for a file input
const stream = e.target.files[0];
Example
const diff = streamListDiff(
[
- { id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
{ id: 3, name: "Item 3" }
],
[
+ { id: 0, name: "Item 0" },
{ id: 2, name: "Item 2" },
+ { id: 3, name: "Item Three" },
],
"id",
{ chunksSize: 2 }
);
Output
diff.on("data", (chunk) => {
// first chunk received (2 object diffs)
[
+ {
+ value: { id: 0, name: "Item 0" },
+ index: 0,
+ previousValue: null,
+ previousIndex: null,
+ status: "added"
+ },
- {
- value: null,
- index: null,
- previousValue: { id: 1, name: "Item 1" },
- previousIndex: 0,
- status: "deleted"
- }
]
// second chunk received (2 object diffs)
[
{
value: { id: 2, name: "Item 2" },
index: 1,
previousValue: { id: 2, name: "Item 2" },
previousIndex: 1,
status: "equal"
},
+ {
+ value: { id: 3, name: "Item Three" },
+ index: 2,
+ previousValue: { id: 3, name: "Item 3" },
+ previousIndex: 2,
+ status: "updated"
+ },
]
});
diff.on("finish", () => console.log("Your data has been processed. The full diff is available."))
diff.on("error", (err) => console.log(err))
import { getTextDiff } from "@donedeal0/superdiff";
Compares two texts and returns a structured diff at a character, word, or sentence level.
Input
previousText: string | null | undefined,
currentText: string | null | undefined,
options?: {
separation?: "character" | "word" | "sentence" // "word" by default
accuracy?: "normal" | "high", // "normal" by default
detectMoves?: boolean // false by default
ignoreCase?: boolean, // false by default
ignorePunctuation?: boolean, // false by default
preserveWhitespace?: boolean, // false by default
locale?: Intl.Locale | string // undefined by default
}
previousText: the original text.currentText: the current text.options
separation whether you want a character, word or sentence based diff.accuracy:
normal (default): fastest mode, simple tokenization.high: slower but exact tokenization. Handles all language subtleties (Unicode, emoji, CJK scripts, locale‑aware segmentation when a locale is provided).detectMoves:
false (default): optimized for readability. Token moves are ignored so insertions don’t cascade and break equality (recommended for UI diffing).true: semantically precise, but noisier — a single insertion shifts all following tokens, breaking equality.ignoreCase: if true, hello and HELLO are considered equal.ignorePunctuation: if true, hello! and hello are considered equal.preserveWhitespace: if true, each token keeps the whitespace preceding it, so the original text can be rebuilt from the diff and whitespace-only edits are detected. Only available in normal accuracy mode.locale: the locale of your text. Enables locale‑aware segmentation in high accuracy mode.Output
type TextDiff = {
type: "text";
status: "added" | "deleted" | "equal" | "updated";
diff: {
value: string;
index: number | null;
previousValue?: string;
previousIndex: number | null;
status: "added" | "deleted" | "equal" | "moved" | "updated";
}[];
};
WITHOUT MOVES DETECTION
This is the default output. Token moves are ignored so insertions don’t cascade and break equality. Updates are rendered as two entries (added + deleted). The algorithm uses longest common subsequence (LCS), similar to GitHub diffs.
Input
getTextDiff(
- "The brown fox jumped high",
+ "The orange cat has jumped",
{ detectMoves: false, separation: "word" }
);
Output
{
type: "text",
+ status: "updated",
diff: [
{
value: 'The',
index: 0,
previousIndex: 0,
status: 'equal',
},
- {
- value: "brown",
- index: null,
- previousIndex: 1,
- status: "deleted",
- },
- {
- value: "fox",
- index: null,
- previousIndex: 2,
- status: "deleted",
- },
+ {
+ value: "orange",
+ index: 1,
+ previousIndex: null,
+ status: "added",
+ },
+ {
+ value: "cat",
+ index: 2,
+ previousIndex: null,
+ status: "added",
+ },
+ {
+ value: "has",
+ index: 3,
+ previousIndex: null,
+ status: "added",
+ },
{
value: "jumped",
index: 4,
previousIndex: 3,
status: "equal",
},
- {
- value: "high",
- index: null,
- previousIndex: 4,
- status: "deleted",
- }
],
}
WITH MOVE DETECTION
If you prefer a semantically precise diff, activate the detectMoves option. Direct token swaps are considered updated.
Input
getTextDiff(
- "The brown fox jumped high",
+ "The orange cat has jumped",
{ detectMoves: true, separation: "word" }
);
Output
{
type: "text",
+ status: "updated",
diff: [
{
value: 'The',
index: 0,
previousIndex: 0,
status: 'equal',
},
+ {
+ value: "orange",
+ index: 1,
+ previousValue: "brown",
+ previousIndex: null,
+ status: "updated",
+ },
+ {
+ value: "cat",
+ index: 2,
+ previousValue: "fox",
+ previousIndex: null,
+ status: "updated",
+ },
+ {
+ value: "has",
+ index: 3,
+ previousIndex: null,
+ status: "added",
+ },
+ {
+ value: "jumped",
+ index: 4,
+ previousIndex: 3,
+ status: "moved",
+ },
- {
- value: "high",
- index: null,
- previousIndex: 4,
- status: "deleted",
- }
],
}
import { getCodeDiff } from "@donedeal0/superdiff";
Compares two codes and returns a structured diff: lines first, then the tokens of each changed line. Whitespace is kept, so indentation changes are reported too.
ℹ️ To diff files, read them first:
getCodeDiff(await previousFile.text(), await currentFile.text()).
Input
previousCode: string | null | undefined,
currentCode: string | null | undefined,
Output
type CodeDiff = {
type: "code";
status: "added" | "deleted" | "equal" | "updated";
diff: {
value: string;
previousValue?: string;
line: number | null;
previousLine: number | null;
status: "added" | "deleted" | "equal" | "updated";
diff?: {
value: string;
previousValue?: string;
status: "added" | "deleted" | "equal" | "updated";
}[];
}[];
};
Lines start at 1. line is null if the line was deleted, previousLine is null if it was added. diff holds the token changes of an updated line.
Input
getCodeDiff(
- "const MAX = 280;\n\nfunction preview(post) {\n return post.body;\n}",
+ "const MAX_LENGTH = 320;\n\nfunction preview(post) {\n const text = post.body;\n return text.slice(0, MAX_LENGTH);\n}"
);
Output
{
type: "code",
+ status: "updated",
diff: [
{
+ value: "const MAX_LENGTH = 320;",
+ previousValue: "const MAX = 280;",
line: 1,
previousLine: 1,
+ status: "updated",
diff: [
{ value: "const", status: "equal" },
+ { value: " MAX_LENGTH", previousValue: " MAX", status: "updated" },
{ value: " =", status: "equal" },
+ { value: " 320", previousValue: " 280", status: "updated" },
{ value: ";", status: "equal" },
],
},
{
value: "",
previousValue: "",
line: 2,
previousLine: 2,
status: "equal",
},
{
value: "function preview(post) {",
previousValue: "function preview(post) {",
line: 3,
previousLine: 3,
status: "equal",
},
{
+ value: " const text = post.body;",
+ previousValue: " return post.body;",
line: 4,
previousLine: 4,
+ status: "updated",
diff: [
+ { value: " const", previousValue: " return", status: "updated" },
+ { value: " text", status: "added" },
+ { value: " =", status: "added" },
{ value: " post", status: "equal" },
{ value: ".", status: "equal" },
{ value: "body", status: "equal" },
{ value: ";", status: "equal" },
],
},
{
+ value: " return text.slice(0, MAX_LENGTH);",
line: 5,
previousLine: null,
+ status: "added",
},
{
value: "}",
previousValue: "}",
line: 6,
previousLine: 5,
status: "equal",
},
],
}
import { getGeoDiff } from "@donedeal0/superdiff";
Returns a structured diff between two geographical coordinates. Supports 9 distance units, locale‑aware output, and two accuracy modes.
Input
previousCoordinates: [number, number] | null | undefined;
coordinates: [number, number] | null | undefined;
options?: {
unit?: "centimeter" | "foot" | "inch" | "kilometer" | "meter" | "mile" | "mile-scandinavian" | "millimeter" | "yard"; // "kilometer" by default
accuracy?: "normal" | "high"; // "normal" by default
maxDecimals?: number; // 2 by default,
locale?: Intl.Locale | string; // "en-US" by default
}
previousCoordinates: the original coordinates ([Longitude, Latitude]).coordinates: the new coordinates ([Longitude, Latitude]).options
unit: the unit used for the returned distance.accuracy:
normal (default): fastest mode, with a small error margin, based on Haversine formula.high: slower but highly precise distance. Based on Vincenty formula.maxDecimals: maximal decimals for the distance. Defaults to 2.locale: the locale of your distance. Enables a locale‑aware distance label.Output
type GeoDiff = {
type: "geo";
status: "added" | "deleted" | "error" | "equal" | "updated";
diff: {
coordinates: [number, number] | null;
previousCoordinates: [number, number] | null;
distance: number;
unit: "centimeter" | "foot" | "inch" | "kilometer" | "meter" | "mile" | "mile-scandinavian" | "millimeter" | "yard";
label: string,
direction: "east" | "north" | "south" | "west" | "north-east" | "north-west" | "south-east" | "south-west" | "stationary";
};
}
Input
getGeoDiff(
- [2.3522, 48.8566],
+ [-0.1278, 51.5074]
);
Coordinates follow GeoJSON order: [longitude, latitude].
Output
{
type: "geo",
+ status: "updated",
diff: {
+ coordinates: [-0.1278, 51.5074],
previousCoordinates: [2.3522, 48.8566],
+ direction: "north-west",
+ distance: 343.56,
+ label: "343.56 kilometers",
+ unit: "kilometer"
}
}
For questions, feedback, or collaboration:
Issues and pull requests are welcome!
557 followers · starred Jan 2023
789 followers · starred Feb 2026
4,362 followers · starred Jan 2026
94 followers · starred Jan 2026
Superdiff provides a rich and readable diff for arrays, objects, code, text and coordinates. It supports stream and file inputs for handling large datasets efficiently, is battle-tested, has zero dependencies, and offers top-tier performance.
See the code
Superdiff provides a rich and readable diff for arrays, objects, code, text and coordinates. It supports stream and file inputs for handling large datasets efficiently, is battle-tested, has zero dependencies, and offers top-tier performance.
ℹ️ The documentation is also available on our website!
Superdiff exports 6 functions:

Comparing millions of entries in real time with streamListDiff

Detecting text changes with getTextDiff

Tracking coordinate changes with getGeoDiff

Diffing deeply nested objects with getObjectDiff

Track code changes with getCodeDiff
| Feature | superdiff | deep-object-diff | deep-diff | diff | microdiff |
|---|---|---|---|---|---|
| Object diff | ✅ | ✅ | ✅ | ❌ | ✅ |
| List diff | ✅ | ❌ | ⚠️ | ❌ | ⚠️ |
| Text diff | ✅ | ❌ | ✅ | ✅ | ❌ |
| Coordinates diff | ✅ | ❌ | ❌ | ❌ | ❌ |
| Code diff | ✅ | ❌ | ❌ | ✅ | ❌ |
| Streaming for huge datasets | ✅ | ❌ | ❌ | ❌ | ❌ |
| Move detection | ✅ | ❌ | ❌ | ❌ | ❌ |
| Output refinement | ✅ | ❌ | ❌ | ❌ | ❌ |
| Zero dependencies | ✅ | ✅ | ❌ | ✅ | ✅ |
Some libraries perform array diffing using index-by-index comparison. This approach cannot reliably detect insertions, deletions, or moves correctly. In those cases, support is marked as partial.
Environment: Node.js 24.12.0 (LTS) • MacBook Pro M2 (2023, Sequoia 15.1) • 16GB RAM.
Method: Warm up runs, then each script is executed 20 times, and we keep the median time. To minimize garbage collection and cross‑benchmark interference, all scenarios are run individually. All benchmark scripts are included so you can reproduce the results locally.
| Scenario | superdiff | arr-diff | deep-diff |
|---|---|---|---|
| 10k items array | 1.84 ms | 32.95 ms | 4.74 ms |
| 100k items array | 17.43 ms | 3363.15 ms | 50.36 ms |
| Scenario | superdiff | deep-object-diff | deep-diff | microdiff |
|---|---|---|---|---|
| 10k flat object keys | 2.27 ms | 2.44 ms | 39.37 ms | 2.24 ms |
| 100k flat object keys | 29.23 ms | 31.86 ms | 3784.50 ms | 29.51 ms |
| 100k nested nodes | 4.25 ms | 9.67 ms | 16.51 ms | 7.26 ms |
| Scenario | superdiff | diff |
|---|---|---|
| 10k words | 1.32 ms | 3.86 ms |
| 100k words | 12.09 ms | 45.64 ms |
| 10k sentences | 1.58 ms | 4.53 ms |
| 100k sentences | 22.31 ms | 55.60 ms |
(Superdiff uses its normal accuracy settings to match diff's behavior)
| Scenario | superdiff | diff |
|---|---|---|
| 1k lines | 0.36 ms | 1.14 ms |
| 10k lines | 9.55 ms | 67.21 ms |
| 100k lines | 911.80 ms | 6995.84 ms |
(diff has no line + token API, so both passes are run; its line pass alone is 66 ms at 10k lines)
👉 Despite providing a full structural diff with a richer output, Superdiff consistently matches or outperforms the fastest alternatives tested. It also scales linearly, even with deeply nested data.
I am grateful to the generous donors of Superdiff!
If you or your company uses this library, please show your support by becoming a sponsor! Your name and company logo will be displayed on the README.md. Premium support is also available.
import { getObjectDiff } from "@donedeal0/superdiff";
Compares two objects and returns a diff for each value and its possible subvalues. Supports deeply nested objects of any value type.
Input
prevData: Record<string, unknown>;
nextData: Record<string, unknown>;
options?: {
ignoreArrayOrder?: boolean, // false by default,
showOnly?: {
statuses: ("added" | "deleted" | "updated" | "equal")[], // [] by default
granularity?: "basic" | "deep" // "basic" by default
}
}
prevData: the original object.nextData: the new object.options
ignoreArrayOrder: if true, ["hello", "world"] and ["world", "hello"] are considered equal, because the two arrays contain the same values, just in a different order.
showOnly: returns only the values whose status you are interested in. It takes two parameters:
statuses: status you want to see in the output (e.g. ["added", "equal"])
granularity:
basic returns only the main keys whose status matches your query.deep can return main keys if some of their nested keys' status match your request. The nested keys are filtered accordingly.Output
type ObjectDiff = {
type: "object";
status: "added" | "deleted" | "equal" | "updated";
diff: Diff[];
};
type Diff = {
key: string;
value: unknown;
previousValue: unknown;
status: "added" | "deleted" | "equal" | "updated";
// recursive diff in case of nested keys
diff?: Diff[];
};
Input
getObjectDiff(
{
id: 54,
user: {
name: "joe",
- member: true,
- hobbies: ["golf", "football"],
age: 66,
},
},
{
id: 54,
user: {
name: "joe",
+ member: false,
+ hobbies: ["golf", "chess"],
age: 66,
},
}
);
Output
{
type: "object",
+ status: "updated",
diff: [
{
key: "id",
value: 54,
previousValue: 54,
status: "equal",
},
{
key: "user",
value: {
name: "joe",
member: false,
hobbies: ["golf", "chess"],
age: 66,
},
previousValue: {
name: "joe",
member: true,
hobbies: ["golf", "football"],
age: 66,
},
+ status: "updated",
diff: [
{
key: "name",
value: "joe",
previousValue: "joe",
status: "equal",
},
+ {
+ key: "member",
+ value: false,
+ previousValue: true,
+ status: "updated",
+ },
+ {
+ key: "hobbies",
+ value: ["golf", "chess"],
+ previousValue: ["golf", "football"],
+ status: "updated",
+ },
{
key: "age",
value: 66,
previousValue: 66,
status: "equal",
},
],
},
],
}
import { getListDiff } from "@donedeal0/superdiff";
Compares two arrays and returns a diff for each entry. Supports duplicate values, primitive values and objects.
Input
prevList: T[];
nextList: T[];
options?: {
showOnly?: ("added" | "deleted" | "moved" | "updated" | "equal")[], // [] by default
referenceKey?: string, // "" by default
ignoreArrayOrder?: boolean, // false by default,
considerMoveAsUpdate?: boolean // false by default
}
prevList: the original list.nextList: the new list.options
showOnly gives you the option to return only the values whose status you are interested in (e.g. ["added", "equal"]).referenceKey will consider an object to be updated rather than added or deleted if one of its keys remains stable, such as its id. This option has no effect on other datatypes.ignoreArrayOrder: if true, ["hello", "world"] and ["world", "hello"] are considered equal, because the two arrays contain the same values, just in a different order.considerMoveAsUpdate: if true, a moved value is considered updated.Output
type ListDiff = {
type: "list";
status: "added" | "deleted" | "equal" | "moved" | "updated";
diff: {
value: unknown;
index: number | null;
previousIndex: number | null;
status: "added" | "deleted" | "equal" | "moved" | "updated";
}[];
};
Input
getListDiff(
- ["mbappe", "mendes", "verratti", "ruiz"],
+ ["mbappe", "messi", "ruiz"]
);
Output
{
type: "list",
+ status: "updated",
diff: [
{
value: "mbappe",
index: 0,
previousIndex: 0,
status: "equal",
},
- {
- value: "mendes",
- index: null,
- previousIndex: 1,
- status: "deleted",
- },
- {
- value: "verratti",
- index: null,
- previousIndex: 2,
- status: "deleted",
- },
+ {
+ value: "messi",
+ index: 1,
+ previousIndex: null,
+ status: "added",
+ },
+ {
+ value: "ruiz",
+ index: 2,
+ previousIndex: 3,
+ status: "moved",
},
],
}
// If you are in a server environment
import { streamListDiff } from "@donedeal0/superdiff/server";
// If you are in a browser environment
import { streamListDiff } from "@donedeal0/superdiff/client";
Streams the diff of two object lists, ideal for large lists and maximum performance.
ℹ️ streamListDiff requires ESM support for browser usage. It will work out of the box if you use a modern bundler (Webpack, Rollup) or JavaScript framework (Next.js, Vue.js).
Input
In a server environment,
Readablerefers to Node.js streams, andFilePathrefers to the path of a file (e.g.,./list.json). Examples are provided in the #usage section below.
prevList: Readable | FilePath | Record<string, unknown>[],
nextList: Readable | FilePath | Record<string, unknown>[],
referenceKey: keyof Record<string, unknown>,
options: {
showOnly?: ("added" | "deleted" | "moved" | "updated" | "equal")[], // [] by default
chunksSize?: number, // 0 by default
considerMoveAsUpdate?: boolean; // false by default
useWorker?: boolean; // true by default
showWarnings?: boolean; // true by default
}
In a browser environment,
ReadableStreamrefers to the browser's streaming API, andFilerefers to an uploaded or local file. Examples are provided in the #usage section below.
prevList: ReadableStream<Record<string, unknown>> | File | Record<string, unknown>[],
nextList: ReadableStream<Record<string, unknown>> | File | Record<string, unknown>[],
referenceKey: keyof Record<string, unknown>,
options: {
showOnly?: ("added" | "deleted" | "moved" | "updated" | "equal")[], // [] by default
chunksSize?: number, // 0 by default
considerMoveAsUpdate?: boolean; // false by default
useWorker?: boolean; // true by default
showWarnings?: boolean; // true by default
}
prevList: the original object list.nextList: the new object list.referenceKey: a key common to all objects in your lists (e.g. id).options
chunksSize the number of object diffs returned by each streamed chunk. (e.g. 0 = 1 object diff per chunk, 10 = 10 object diffs per chunk).showOnly gives you the option to return only the values whose status you are interested in (e.g. ["added", "equal"]).considerMoveAsUpdate: if true a moved value is considered updated.useWorker: if true, the diff will be run in a worker for maximum performance. Only recommended for large lists (e.g. +100,000 items).showWarnings: if true, potential warnings will be displayed in the console.⚠️ Warning: using Readable streams may impact workers' performance since they need to be converted to arrays. Consider using arrays or files for optimal performance. Alternatively, you can turn the
useWorkeroption off.
Output
The objects diff are grouped into arrays - called chunks - and are consumed thanks to an event listener. You have access to 3 events:
data: to be notified when a new chunk of object diffs is available.finish: to be notified when the stream is finished.error: to be notified if an error occurs during the stream.interface StreamListener<T> {
on(event: "data", listener: (chunk: StreamListDiff<T>[]) => void);
on(event: "finish", listener: () => void);
on(event: "error", listener: (error: Error) => void);
}
type StreamListDiff<T extends Record<string, unknown>> = {
value: T | null;
index: number | null;
previousValue: T | null;
previousIndex: number | null;
status: "added" | "deleted" | "moved" | "updated" | "equal";
};
Input
You can send streams, file paths, or arrays as input:
If you are in a server environment
// for a simple array
const stream = [{ id: 1, name: "hello" }]
// for a large array
const stream = Readable.from(list, { objectMode: true });
// for a local file
const stream = path.resolve(__dirname, "./list.json");
If you are in a browser environment
// for a simple array
const stream = [{ id: 1, name: "hello" }]
// for a large array
const stream = new ReadableStream({
start(controller) {
list.forEach((value) => controller.enqueue(value));
controller.close();
},
});
// for a local file
const stream = new File([JSON.stringify(file)], "file.json", { type: "application/json" });
// for a file input
const stream = e.target.files[0];
Example
const diff = streamListDiff(
[
- { id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
{ id: 3, name: "Item 3" }
],
[
+ { id: 0, name: "Item 0" },
{ id: 2, name: "Item 2" },
+ { id: 3, name: "Item Three" },
],
"id",
{ chunksSize: 2 }
);
Output
diff.on("data", (chunk) => {
// first chunk received (2 object diffs)
[
+ {
+ value: { id: 0, name: "Item 0" },
+ index: 0,
+ previousValue: null,
+ previousIndex: null,
+ status: "added"
+ },
- {
- value: null,
- index: null,
- previousValue: { id: 1, name: "Item 1" },
- previousIndex: 0,
- status: "deleted"
- }
]
// second chunk received (2 object diffs)
[
{
value: { id: 2, name: "Item 2" },
index: 1,
previousValue: { id: 2, name: "Item 2" },
previousIndex: 1,
status: "equal"
},
+ {
+ value: { id: 3, name: "Item Three" },
+ index: 2,
+ previousValue: { id: 3, name: "Item 3" },
+ previousIndex: 2,
+ status: "updated"
+ },
]
});
diff.on("finish", () => console.log("Your data has been processed. The full diff is available."))
diff.on("error", (err) => console.log(err))
import { getTextDiff } from "@donedeal0/superdiff";
Compares two texts and returns a structured diff at a character, word, or sentence level.
Input
previousText: string | null | undefined,
currentText: string | null | undefined,
options?: {
separation?: "character" | "word" | "sentence" // "word" by default
accuracy?: "normal" | "high", // "normal" by default
detectMoves?: boolean // false by default
ignoreCase?: boolean, // false by default
ignorePunctuation?: boolean, // false by default
preserveWhitespace?: boolean, // false by default
locale?: Intl.Locale | string // undefined by default
}
previousText: the original text.currentText: the current text.options
separation whether you want a character, word or sentence based diff.accuracy:
normal (default): fastest mode, simple tokenization.high: slower but exact tokenization. Handles all language subtleties (Unicode, emoji, CJK scripts, locale‑aware segmentation when a locale is provided).detectMoves:
false (default): optimized for readability. Token moves are ignored so insertions don’t cascade and break equality (recommended for UI diffing).true: semantically precise, but noisier — a single insertion shifts all following tokens, breaking equality.ignoreCase: if true, hello and HELLO are considered equal.ignorePunctuation: if true, hello! and hello are considered equal.preserveWhitespace: if true, each token keeps the whitespace preceding it, so the original text can be rebuilt from the diff and whitespace-only edits are detected. Only available in normal accuracy mode.locale: the locale of your text. Enables locale‑aware segmentation in high accuracy mode.Output
type TextDiff = {
type: "text";
status: "added" | "deleted" | "equal" | "updated";
diff: {
value: string;
index: number | null;
previousValue?: string;
previousIndex: number | null;
status: "added" | "deleted" | "equal" | "moved" | "updated";
}[];
};
WITHOUT MOVES DETECTION
This is the default output. Token moves are ignored so insertions don’t cascade and break equality. Updates are rendered as two entries (added + deleted). The algorithm uses longest common subsequence (LCS), similar to GitHub diffs.
Input
getTextDiff(
- "The brown fox jumped high",
+ "The orange cat has jumped",
{ detectMoves: false, separation: "word" }
);
Output
{
type: "text",
+ status: "updated",
diff: [
{
value: 'The',
index: 0,
previousIndex: 0,
status: 'equal',
},
- {
- value: "brown",
- index: null,
- previousIndex: 1,
- status: "deleted",
- },
- {
- value: "fox",
- index: null,
- previousIndex: 2,
- status: "deleted",
- },
+ {
+ value: "orange",
+ index: 1,
+ previousIndex: null,
+ status: "added",
+ },
+ {
+ value: "cat",
+ index: 2,
+ previousIndex: null,
+ status: "added",
+ },
+ {
+ value: "has",
+ index: 3,
+ previousIndex: null,
+ status: "added",
+ },
{
value: "jumped",
index: 4,
previousIndex: 3,
status: "equal",
},
- {
- value: "high",
- index: null,
- previousIndex: 4,
- status: "deleted",
- }
],
}
WITH MOVE DETECTION
If you prefer a semantically precise diff, activate the detectMoves option. Direct token swaps are considered updated.
Input
getTextDiff(
- "The brown fox jumped high",
+ "The orange cat has jumped",
{ detectMoves: true, separation: "word" }
);
Output
{
type: "text",
+ status: "updated",
diff: [
{
value: 'The',
index: 0,
previousIndex: 0,
status: 'equal',
},
+ {
+ value: "orange",
+ index: 1,
+ previousValue: "brown",
+ previousIndex: null,
+ status: "updated",
+ },
+ {
+ value: "cat",
+ index: 2,
+ previousValue: "fox",
+ previousIndex: null,
+ status: "updated",
+ },
+ {
+ value: "has",
+ index: 3,
+ previousIndex: null,
+ status: "added",
+ },
+ {
+ value: "jumped",
+ index: 4,
+ previousIndex: 3,
+ status: "moved",
+ },
- {
- value: "high",
- index: null,
- previousIndex: 4,
- status: "deleted",
- }
],
}
import { getCodeDiff } from "@donedeal0/superdiff";
Compares two codes and returns a structured diff: lines first, then the tokens of each changed line. Whitespace is kept, so indentation changes are reported too.
ℹ️ To diff files, read them first:
getCodeDiff(await previousFile.text(), await currentFile.text()).
Input
previousCode: string | null | undefined,
currentCode: string | null | undefined,
Output
type CodeDiff = {
type: "code";
status: "added" | "deleted" | "equal" | "updated";
diff: {
value: string;
previousValue?: string;
line: number | null;
previousLine: number | null;
status: "added" | "deleted" | "equal" | "updated";
diff?: {
value: string;
previousValue?: string;
status: "added" | "deleted" | "equal" | "updated";
}[];
}[];
};
Lines start at 1. line is null if the line was deleted, previousLine is null if it was added. diff holds the token changes of an updated line.
Input
getCodeDiff(
- "const MAX = 280;\n\nfunction preview(post) {\n return post.body;\n}",
+ "const MAX_LENGTH = 320;\n\nfunction preview(post) {\n const text = post.body;\n return text.slice(0, MAX_LENGTH);\n}"
);
Output
{
type: "code",
+ status: "updated",
diff: [
{
+ value: "const MAX_LENGTH = 320;",
+ previousValue: "const MAX = 280;",
line: 1,
previousLine: 1,
+ status: "updated",
diff: [
{ value: "const", status: "equal" },
+ { value: " MAX_LENGTH", previousValue: " MAX", status: "updated" },
{ value: " =", status: "equal" },
+ { value: " 320", previousValue: " 280", status: "updated" },
{ value: ";", status: "equal" },
],
},
{
value: "",
previousValue: "",
line: 2,
previousLine: 2,
status: "equal",
},
{
value: "function preview(post) {",
previousValue: "function preview(post) {",
line: 3,
previousLine: 3,
status: "equal",
},
{
+ value: " const text = post.body;",
+ previousValue: " return post.body;",
line: 4,
previousLine: 4,
+ status: "updated",
diff: [
+ { value: " const", previousValue: " return", status: "updated" },
+ { value: " text", status: "added" },
+ { value: " =", status: "added" },
{ value: " post", status: "equal" },
{ value: ".", status: "equal" },
{ value: "body", status: "equal" },
{ value: ";", status: "equal" },
],
},
{
+ value: " return text.slice(0, MAX_LENGTH);",
line: 5,
previousLine: null,
+ status: "added",
},
{
value: "}",
previousValue: "}",
line: 6,
previousLine: 5,
status: "equal",
},
],
}
import { getGeoDiff } from "@donedeal0/superdiff";
Returns a structured diff between two geographical coordinates. Supports 9 distance units, locale‑aware output, and two accuracy modes.
Input
previousCoordinates: [number, number] | null | undefined;
coordinates: [number, number] | null | undefined;
options?: {
unit?: "centimeter" | "foot" | "inch" | "kilometer" | "meter" | "mile" | "mile-scandinavian" | "millimeter" | "yard"; // "kilometer" by default
accuracy?: "normal" | "high"; // "normal" by default
maxDecimals?: number; // 2 by default,
locale?: Intl.Locale | string; // "en-US" by default
}
previousCoordinates: the original coordinates ([Longitude, Latitude]).coordinates: the new coordinates ([Longitude, Latitude]).options
unit: the unit used for the returned distance.accuracy:
normal (default): fastest mode, with a small error margin, based on Haversine formula.high: slower but highly precise distance. Based on Vincenty formula.maxDecimals: maximal decimals for the distance. Defaults to 2.locale: the locale of your distance. Enables a locale‑aware distance label.Output
type GeoDiff = {
type: "geo";
status: "added" | "deleted" | "error" | "equal" | "updated";
diff: {
coordinates: [number, number] | null;
previousCoordinates: [number, number] | null;
distance: number;
unit: "centimeter" | "foot" | "inch" | "kilometer" | "meter" | "mile" | "mile-scandinavian" | "millimeter" | "yard";
label: string,
direction: "east" | "north" | "south" | "west" | "north-east" | "north-west" | "south-east" | "south-west" | "stationary";
};
}
Input
getGeoDiff(
- [2.3522, 48.8566],
+ [-0.1278, 51.5074]
);
Coordinates follow GeoJSON order: [longitude, latitude].
Output
{
type: "geo",
+ status: "updated",
diff: {
+ coordinates: [-0.1278, 51.5074],
previousCoordinates: [2.3522, 48.8566],
+ direction: "north-west",
+ distance: 343.56,
+ label: "343.56 kilometers",
+ unit: "kilometer"
}
}
For questions, feedback, or collaboration:
Issues and pull requests are welcome!
557 followers · starred Jan 2023
789 followers · starred Feb 2026
4,362 followers · starred Jan 2026
94 followers · starred Jan 2026