export const shallowEquals = (a: unknown, b: unknown) => {
// === 가 아닌 Object.is() 사용
if (Object.is(a, b)) {
return true;
}
const isNull = a === null || b === null;
const isNotObject = typeof a !== "object" || typeof b !== "object";
// 둘 중에 하나라도 null 또는 object 타입이 아닐 경우
if (isNull || isNotObject) {
return false;
}
// 서로 다른 참조를 가진 객체 또는 배열
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
/* 배열 얕게 비교 */
if (aKeys.length !== bKeys.length) {
return false;
}
/* 중첩된 구조 깊게 비교를 위한 a, b 를 인덱싱 가능한 타입으로 간주하도록 설정 */
const objA = a as Record<string, unknown>;
const objB = b as Record<string, unknown>;
for (const key of aKeys) {
// b 가 key 를 가지고 있지 않거나, a와 b의 값이 다르다는 조건
if (!Object.hasOwn(b, key) || !Object.is(objA[key], objB[key])) {
return false;
}
}
return true;
};
Object.is() 는 두 개의 값이 같은 값인지를 결정하는 내장 함수(built-in function)입니다. "같다"는 것을 판단하는 기준이 우리가 흔히 사용하는 == (동등 연산자)나 === (일치 연산자)와 미묘하게 달라서, 특정 엣지 케이스(edge case)들을 더 정확하게 처리할 수 있습니다.
=== (일치 연산자)와의 차이점
대부분의 경우 Object.is(a, b)는 a === b와 동일하게 동작하지만, 두 가지 중요한 예외가 있습니다.
NaN (Not a Number)
+0과 -0
shallowEquals에서 Object.is()를 먼저 사용하는 이유
💡
Object.is()는 ===보다 더 엄격하고 정확하게 "같은 값"을 비교하는 도구라고 생각하시면 됩니다.
shallowEquals의 첫 단계에서 이를 사용하면, 가장 간단하고 확실한 케이스들을 빠르게 처리하고 넘어갈 수 있습니다.
➡️ TypeScript가 a와 b의 정확한 타입을 추론하지 못하기 때문
Record<Keys, Type> 이란?
Record는 TypeScript에서 제공하는 특별한 도구(유틸리티 타입)로, 객체의 키(key)와 값(value)의 타입을 미리 정의할 때 사용합니다.
Record<Keys, Type>은 이렇게 읽을 수 있습니다.
"이 객체의 모든 키는 Keys 타입에 속하고, 모든 값은 Type 타입이어야 한다."
shallowEquals.ts 코드에서의 사용
1 // ...
2 // 이 시점에서 a와 b는 object 타입이지만,
3 // TypeScript는 a['someKey'] 같은 접근을 허용하지 않음.
4 // a와 b의 타입은 아직 { a: 1 } 인지, { b: 'hello' } 인지 모르는 상태.
5
6 const objA = a as Record<string, unknown>;
7 const objB = b as Record<string, unknown>;
8
9 // 이제 TypeScript는 objA와 objB를
10 // "키는 문자열이고, 값은 아무거나(unknown) 올 수 있는 객체"로 인식함.
11
12 for (const key of aKeys) {
13 // 따라서 objA[key] 와 같은 코드에서 에러가 발생하지 않음.
14 if (!Object.hasOwn(b, key) || !Object.is(objA[key], objB[key])) {
15 return false;
16 }
17 }
18 // ...
왜 Record가 필요한가? (문제 상황)
shallowEquals 함수는 어떤 타입의 값이든 받을 수 있도록 파라미터 a와 b를 unknown으로 받습니다. 그리고 typeof a === 'object'와 같은 코드로 a가 객체라는 것까지 확인했습니다.
하지만 TypeScript 입장에서는 a가 그냥 "객체"라는 사실만 알 뿐, 어떤 키들을 가지고 있는지, 그 키를 사용해 값에 접근(a[key])할 수 있는지는 전혀 모릅니다.
그래서 a[key] 같은 코드를 쓰면 TypeScript가 객체가 문자열 key로 값을 꺼낼 수 있는 구조인지 보장을 못한다는 에러를 발생합니다.
Record로 어떻게 해결하는가?
const objA = a as Record<string, unknown>;
이 코드는 일종의 "타입 강제 선언" 또는 "타입 단언(assertion)" 입니다. 우리는 TypeScript 컴파일러에게 다음과 같이 말해주는 것과 같습니다.
💬: "TypeScript야,
a는 막연한 object가 아님.
a는 키가 문자(string)이고 값은 일단 아무거나(unknown) 들어있는 객체임.
Record<string, unknown> 타입이니까 나 믿고 컴파일 ㄱㄱ."
- string: 키의 타입. Object.keys()가 문자열 배열을 반환하므로, 키는 string이 맞습니다.
- unknown: 값의 타입. 객체의 값이 숫자일 수도, 문자열일 수도, 또 다른 객체일 수도 있으므로, 어떤 타입이든 가능하다는 의미에서 unknown을 사용합니다.
💡 Record 타입으로 단언을 해주고 나면, TypeScript는 objA를 "문자열 키로 접근 가능한 객체"로 인식하게 됩니다.
그 결과, for 루프 안에서 objA[key]처럼 키를 사용해 객체의 값에 접근하는 코드가 타입 에러 없이 안전하게 통과되는 것입니다.
... shallowEquals 와 동일 생략
for (const key of aKeys) {
// 깊은 비교를 위하여 재귀 호출
if (!Object.hasOwn(b, key) || !deepEquals(objA[key], objB[key])) {
return false;
}
}
... 생략
deepEquals 함수 자신에게 다시 인자로 넣어 호출해야 합니다.
1 // shallowEquals 에서는...
2 !Object.is(objA[key], objB[key])
3
4 // deepEquals 에서는...
5 !deepEquals(objA[key], objB[key]) // <--- 바로 이 부분!
같은 듯 다르다.