typescript 35 lines · 9 steps

Building a recursive deep-diff in TypeScript

A recursive function that compares two nested objects and reports exactly what was added, removed, or changed.

Explained by highlit
1type Change =
2 | { kind: "added"; path: string; value: unknown }
3 | { kind: "removed"; path: string; value: unknown }
4 | { kind: "updated"; path: string; from: unknown; to: unknown };
5 
6function isRecord(value: unknown): value is Record<string, unknown> {
7 return typeof value === "object" && value !== null && !Array.isArray(value);
8}
9 
10export function deepDiff(before: unknown, after: unknown, base = ""): Change[] {
11 if (Object.is(before, after)) return [];
12 
13 if (!isRecord(before) || !isRecord(after)) {
14 return [{ kind: "updated", path: base, from: before, to: after }];
15 }
16 
17 const changes: Change[] = [];
18 const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
19 
20 for (const key of keys) {
21 const path = base ? `${base}.${key}` : key;
22 const hasBefore = key in before;
23 const hasAfter = key in after;
24 
25 if (hasBefore && !hasAfter) {
26 changes.push({ kind: "removed", path, value: before[key] });
27 } else if (!hasBefore && hasAfter) {
28 changes.push({ kind: "added", path, value: after[key] });
29 } else {
30 changes.push(...deepDiff(before[key], after[key], path));
31 }
32 }
33 
34 return changes;
35}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1A discriminated union lets each result variant carry exactly the fields it needs, so consumers can switch safely on kind.
  2. 2Recursion naturally mirrors nested data — each level handles one object and delegates its children to the same function.
  3. 3Unioning both objects' keys ensures additions and removals surface, not just changes to shared keys.

Related explainers

typescript
import { Component, computed, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { CommonModule } from '@angular/common';
 

Building a signup stepper in Angular

signals reactive-forms form-validation
Intermediate 10 steps
java
public final class ShippingAddress {
    private final String recipient;
    private final String line1;
    private final String line2;

Building immutable objects with the Builder pattern

builder-pattern immutability validation
Intermediate 7 steps
typescript
import { DynamicModule, Module, Provider } from '@nestjs/common';
import Redis, { RedisOptions } from 'ioredis';
 
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');

Building a dynamic Redis module in NestJS

dependency-injection dynamic-modules provider-tokens
Intermediate 8 steps
java
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;

Converting timestamps across time zones in Java

date-time time-zones instant
Intermediate 6 steps
typescript
type Operator = "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
 
type Row = Record<string, unknown>;
 

A type-safe SQL query builder in TypeScript

builder pattern generics method chaining
Intermediate 8 steps
typescript
import { Directive, ElementRef, HostListener, Input, OnDestroy, OnInit } from '@angular/core';
 
@Directive({
  selector: '[appTrapFocus]',

Building a focus-trap directive in Angular

accessibility focus-management dom
Intermediate 9 steps

Share this explainer

Here's the card — post it anywhere.

Building a recursive deep-diff in TypeScript — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code