배열이나 객체의 요소들을 펼쳐 주는 연산자
const arr1 = [1,2,3]
const arr2 = [3,4,5]
const mergedArr = [...arr1, ...arr2] // [1,2,3,3,4,5], number[]
const obj1 = { a:1, b:2 }
const obj2 = { a:2, c:3 }
const mergedObj = { ...obj1, ...obj2 } // { a:2, b:2, c:3 } { a: number; b: number; c: number; }
object에서 같은 키값을 가지면 덮어씌워버린다.
object의 경우 객체의 own property만을 복사한다.
class에서 instance를 만들게 되면 크게 두 종류의 프로퍼티가 있다.
class Person {
private id: number;
name: string;
constructor(id:number, name:string) {
this.id = id;
this.name = name;
}
greet(){
return `hello world ${this.name}!`
}
}
const person = new Person(1,'Arthur');
const person2 = { ...person } // { name: string }
class에서 own property는 아래 두개다.
그 중 id의 경우 private이기때문에 외부에서 접근이 불가능하기 때문에 실제로는 복사되더라도 타입레벨에서는 없는것으로 추론된다.
(실제 ts-ignore를 하고 console.log(person2.id)
를 해보면 1이 나온다.)
greet()
와 같은 메서드의 경우 own property가 아닌 prototype에 위치하기 때문에 복사되지 않는다.
익명함수로 선언을 하게되면 method도 복사 가능하다
class Person {
...
greet = () => {
return `hello world ${this.name}!`
}
}
const person = new Person(1,'Arthur');
const person2 = { ...person } // { name: string; greet: ()=> string}
ES2022에서 추가된 class private field를 사용하게 되면 실제로 복사도 되지 않는다.
class Person {
#id: number
...
}
const person = new Person(1,'Arthur');
const person2 = { ...person } // { name: string;}
//@ts-ignore
console.log(person2.id) // undefined
객체에서는 모든 선언된 프로퍼티가 own property이기 때문에 전부 복사된다.
const obj = {
id: 123,
name: 'Arthur',
greet() {
return `Hello, ${this.name}!`;
}
}
const obj2 = { ...obj } // { id: number; name: string; greet(): string; }
사실 이 주제를 위해서 이 글을 썼다고 봐도 과언이 아니다.
사내에서 부끄럽지만 테스트코드 없이 올린 코드가 배포되었는데 런타임에서 에러가 났다. 물론 큰 문제 없이 데이터 보정을 바로 하고 넘어가긴 했지만 서비스가 미국 서비스라 새벽에 했다는거 자체가 문제였다...
그래서 코드를 분석하던 중 컴파일 에러가 났어야 했던 것 같은 부분에서 나지 않아서 찾아본 결과 class를 spread할때와 object를 spread 할 때 타입추론 내부동작이 달라서 컴파일 오류가 나지 않았던 것이다.
해당 코드는 아래와 비슷한 코드였다.
class ABC {
id: string;
a: string;
constructor(args: { a: string }) {
this.id = "1";
this.a = args.a;
}
with(args: { a?: string }) {
const abc = new ABC({
...this,
a: args.a, // why not error?
});
abc.id = this.id;
return abc;
}
}
with
method를 통해 업데이트 된 새로운 객체를 만들어 주는 동작을 구현했는데 with
에서는 a가 optional로, constructor에서는 a가 require로 되어있음에도 컴파일 에러가 나지 않았고, 그 결과 런타임에서 실제로 undefined로 들어가는 순간 a: undefined로 참조되어 this.a = undefined로 실행되어 초기화 되는 참사가 일어났다.
const obj1 = { a: 1 }
const obj2 = { b: 2 }
const obj3 = { ...obj1, ...obj2 } // { a: number; b: number; }
프로퍼티의 합집합을 고려하기 때문에 a와 b 둘다 정확하게 추론해준다.
const obj1 = { a: 1, b:2, c:3 }
const obj2 = { b: "b", c:"c", d:"d" }
const obj3 = { ...obj1, ...obj2 } // { a: number; b:string; c:string; d:string; }
나중에 오는 객체의 타입이 우선시 되어 추론되기 때문에 같은 프로퍼티를 가졌다면 덮어씌워진 타입이 추론된다.
타입스크립트는 구조적 타입 시스템을 사용하는데, 객체 리터럴을 직접 할당할 때는 초과 프로퍼티를 체크한다.
그러나 class instance를 spread하게 되면 초과 프로퍼티를 엄격하게 체크하지 않기 때문에 class instance를 spread했을때 해당하는 타입들이 전부 있다고 판단하면 추가적으로 프로퍼티를 검사하지 않는다.
object에 비해 class instance는 private/protected property, 생성자, 메서드 등을 가지고 있고, 더 복잡한 내부 구조를 가지고 있기 때문에 생략한다고 한다. by 잼미니
그래서 아래 코드를 보면
class ABC {
id: string;
a: string;
constructor(args: { a: string }) {
this.id = "1";
this.a = args.a;
}
with(args: { a?: string }) {
const abc = new ABC({
...this,
a: args.a, // why not error?
});
abc.id = this.id;
return abc;
}
}
ABC라는 클래스에는 이미 a: string
이라는 프로퍼티를 가지고 있기 때문에 추가적으로 a:args.a
의 타입체크를 하지 않아 컴파일 에러가 나타나지 않는다.
해당 코드는 여기 에서 확인할 수 있다.