javascript는 거의 모든 것을 객체로 다룬다. 따라서 typescript를 잘 다루기 위해서는 객체를 능숙하게 다룰 필요가 있는데, 이번에는 typescript에서 객체에 대한 type을 어떻게 지정하는지와 객체의 할당가능성에 대해서 알아보자.

🍀 객체 타입

javascript에서는 없는 property에 접근하면 암묵적으로 undefined 란 값을 할당한다. 하지만, 코드를 짜면서 불필요한 property나 없는데 있다고 생각한 property를 가지고 코드를 짠다면 쉽게 오류를 찾을 수 없을 것이다. typescript 덕분에 이러한 문제를 해결할 수 있는데 typescript에서는 없는 property에 접근하면 오류 를 발생시킨다.

없는 property에 접근 시

const person = {
	name: 'someone',
	age: 20
}

person.name; // string
person.age; // number

person.address; // ❌ => Error

위의 코드를 보면 person이란 객체에 없는 property인 address 에 접근하면 typescript는 아래와 같은 오류를 출력한다.

그리고 에러에서 보면 나는 객체의 타입을 지정해주지 않았는데 { name: string; age: number } 라고 typescript가 추론한 것을 볼 수 있다.

그러면 typescript에서는 어떻게 객체에 대한 타입을 지정하는지 살펴보자

1️⃣ 객체 타입 선언

객체에 대한 타입 정의는 아래와 같이 정의할 수 있다.

객체 타입 정의

{
	객체에 들어갈 property명1: property의 타입1;
	객체에 들어갈 property명2: property의 타입2;
}

객체 타입을 정의하는 방법에는 3가지가 있는데 type assertion, type alias, interface 로 할 수 있다.

type assertion

const tom: {
	name: string;
	age: number;
} = {
	name: 'tom',
	age: 20
}

type alias

type Person = {
	name: string;
	age: number;
}

const tom: Person = {
	name: 'tom',
	age: 20
}

interface

interface Person {
	name: string;
	age: number;
}

const tom: Person = {
	name: 'tom',
	age: 20
}

type alias & interface 를 이용한 타입 선언은 type assertion과 다르게 몇 가지의 장점을 갖는다.

  1. type명을 보고 값에 대한 정보를 파악할 수 있다.
  2. 오류 메세지의 가독성이 좋다.

예를 들어 type assertion으로 아래와 같이 변수에 대한 타입을 지정한다고 해 보자. 대신 그 전에 변수명을 알아볼 수 없게끔 네이밍한다고 가정하자.

const something: {
	name: string;
	canSwim: boolean;
	canRun: boolean;
	...//
} = {...}

위 코드에서 타입만을 보고 어떠한 정보인지 파악할 수 있냐고 물어보면 모른다. 일단 난, something의 타입정보를 보면 그저 이름이 존재하고 수영을 할 수 있냐 없냐 정도밖에는 알지 못한다.

동물원에서 키우는 동물들일 수도 있고 각 사람에 대한 능력을 나타낼 수도 있다.

🤔 하지만, type alias 또는 interface 를 이용하여 type 정의를 하면 어떨까?

type TypePersonAbilities = {
	name: string;
	canSwim: boolean;
	canRun: boolean;
}

interface InterfacePersonAbilities {
	name: string;
	canSwim: boolean;
	canRun: boolean;
}

const something: TypePersonAbilities = {...}
const something1: InterfacePersonAbilities = {...}

type에 지정해준 변수명만 보고도 아~~ 사람에 대한 능력을 나타내는 정보구나!!! 라고 단번에 알아차릴 수 있는 장점이 존재한다.

또한 아래와 같이 없는 key값에 접근할 때 뜨는 오류를 살펴보자

const tom: {
	name: string;
	age: number;
} = {
	name: 'tom',
	age: 20
}

tom.address

페이지의 상단에 있는 같은 코드지만 { name: string; age: number} 와 같이 그저 객체에 대한 type정의밖에 찾아볼 수 없다.

하지만, type alias또는 interface로 타입을 정의하여 오류를 발생시켜보면 아래와 같이 나온다.

Person타입에 address property가 없다는 오류를 볼 수 있다. 이를 통해서 Person type을 정의한 곳에 가서 수정을 하던 사람이 가질 수 없는 특징인 fly를 잘못하여 접근 할 경우 코드가 잘못됨을 바로 감지할 수 있다. 이처럼 type assertion과 달리 type alias & interface를 활용하여 타입을 정의하면 장점이 존재한다.

2️⃣ 구조적 타이핑

typescript 타입 시스템은 structurally type(구조적으로 타입화)되어 있다고 한다. 이는 매개변수나 변수가 객체타입으로 선언되면 객체타입에서 지정한 property들을 가져야 함을 의미한다.

예시

type A = {
    a: string;
}

type B = {
    b: number;
}

const hasAAndB = {
    a: 'value',
    b: 10
}

let a: A = hasAAndB; // ✅
let b: B = hasAAndB; // ✅

hasAAndB는 타입스크립트가 {a: string; b: number} type으로 추론하여 구조적으로 type A와 type B의 조건들을 충족한다고 생각하여 hasAAndB의 값을 할당하는 것에 문제를 제기하지 않는 것이다.

하지만, 이는 경우에 따라 오류가 될 수도 있고 안 될 수도 있다. 이에 대한 설명은 아래(초과속성검사 대상자 부분)에 있다.

1. 사용 검사

typescript에서 사용 검사란 객체타입에 지정된 property가 할당하려는 값의 property로서 존재하는 지와 property의 타입이 맞는지 검사하는 것이다.

property가 할당하려는 값의 property로서 존재하는 지에 대한 예시

type Person = {
	name: string
	age: number
}

const tom: Person = { name: 'tom' } // ❌

Person이란 type은 name, age란 property를 무조건 가져야 함 을 뜻하는데 tom이란 변수에 age property가 빠짐 을 볼 수 있다. 말 그대로 typescript는 type에서 정의한 property가 존재하는 지 검사한다.

따라서 위의 코드는 사용검사의 조건에 부합하지 못해 아래와 같은 오류를 출력한다…

property의 타입이 맞는지 검사하는 예시

type Person = {
	name: string
	age: number
}

const tom: Person = { name: 'tom', age: true } // ❌

Person의 ages는 number타입으로 정의했지만 tom에 할당되는 객체의 age property는 boolean type을 할당했다. 이는 Person type에 정의한 age의 타입에 위배된다.

따라서 typescript는 아래와 같이 정의한 타입과 일치하지 않음 으로 아래와 같이 오류를 출력한다.

2. 초과 속성 검사(Excess Property Checks)

정의한 타입의 속성보다 더 많은 속성을 담은 객체 값을 할당하는지에 대한 검사가 초과 속성 검사 이다. 여기서 초과 속성 검사가 되는 경우가 있고 안 되는 경우가 있는데 이를 잘 활용하여 초과 속성 검사를 진행할 수 있다. 이를 나누는 기점은 참조에 의한 초기값 할당은 대상자가 되지 않으며 초기값을 리터럴 값 자체를 넣으면 초과속성 대상자가 된다.

초과 속성 대상자 O: 변수를 선언과 동시에 객체 리터럴 값 자체를 할당할 시
초과 속성 대상자 X: 객체 리터럴 값을 참조하고 있는 변수를 통해 할당할 시

즉, 한마디로 객체 리터럴을 직접 할당하거나 인수로 전달할 때는 초과 속성 검사를 진행한다.

👉🏻 초과 속성 검사 대상의 경우

type Person = {
    name: string;
    age: number
}

const tom: Person = { name: 'tom', age: 20, address: 'seoul'} // ❌

Person type인 person이란 변수에 객체 리터럴 값을 초기값으로 할당해주는 것을 볼 수 있다. 하지만 Person에 정의한 type의 property는 name과 age 밖에 존재하지 않는다. address라는 property를 넣어주었기 때문에 typescript는 넣어주는 초기값(객체 리터럴 값)에 대해 초과 속성 검사를 시행 한다. 따라서 Person이란 타입에 주어진 초기값을 할당할 수 없다는 아래와 같은 오류를 출려한다.

👉🏻 초과 속성 검사 대상이 안 되는 경우

type Person = {
    name: string;
    age: number
}

const person = {
	name: 'tom',
	age: 20,
	address: 'seoul'
}

const tom: Person = person; // ⭕️

분명 Person type을 가지는 tom이라는 변수에 address property가 담긴 객체를 초기값으로 할당된 부분은 동일하다. 하지만, 타입스크립트는 위에서 structurally type(구조적으로 타입화)되어 있다고 언급했다. 따라서 typescript는 구조적으로 Person type에 위배되는 것이 없으므로 아무런 불만을 표시하지 않는다.

나는 책을 읽으면서 말이 너무 어려워 참조에 의한 초기값 할당은 초과 속성 검사 대상자 가 아니라고 표현하는 것이 쉽다고 판단했다.

🤔 여기서 드는 의문 점이 있다…. 내가 보기에 초과 속성 검사를 사용하면 추후에 필요없는 속성값에 의한 잠재적인 오류를 차단할 수 있을 것 같아 웬만해서는 초과 속성 검사 대상자에 올리는 것이 좋아 보인다….

바로 위의 코드에 person: Person으로 타입을 지정하면 person변수도 초과 속성 검사를 진행할 수 있다. 하지만, 내가 들은 바로는 typescript에 의해 타입을 추론할 수 있으면 추론하게끔 놔두라는 글을 본 적이 있는데… 이것은 나중에 공부해 가면서 찾아봐야겠다…

아 그리고 as를 이용한 문법을 통해 초과 속성 검사를 회피할 수도 있다.


type Person = {
    name: string;
    age: number
}

const tom: Person = { 
  name: 'tom', 
  age: 20, 
  address: 'seoul'
} as Person // 👉🏻 as를 이용한 초과속성검사 회피

3. 선택적 속성(Optional Property)

선택적 속성은 ? keyword를 사용하여 속성(property)가 존재할 지 말 지에 대한 여부이다. ?(선택적 속성)을 사용한다면 존재할 수도 없을 수도 있기 때문에 undefined 란 type이 기본적으로 정의된다.

선택적 속성은 언제 쓰일까? 생각해보면 동일한 속성을 가질 수 있는 개체이지만 개체마다 다른 특징을 기술하고 싶을 때 사용하면 좋을 것 같다. 아래 코드 예시를 살펴보자

type Animal = {
  width: number;
	height: number;
	runPerKilo?: boolean;
	swimPerKilo?: bloolean;
}

동물은 가로 몸통 길이와 세로 길이를 공통적으로 가질 수 있다. 하지만, 대체적으로 육지동물들은 뛸 수 있고 해양생물들은 수영할 수 있다고 하면 runPerKilo와 swimPerKilo 는 선택적인 특징(속성)이 될 수 있다. 따라서 선택적 속성을 사용하여 말 그대로 속성을 선택적으로 택할 수 있다.

const whale: Animal = {
	widht: 500,
	height: 200,
	swimPerKilo: 20
}

const tiger: Animal = {
	widht: 200,
	height: 100,
	runPerKilo: 60
}

3️⃣ 객체 타입 유니언

객체도 원시 타입을 유니언으로 정의하는 것 처럼 동일하게 할 수 있다.변수의 타입에 여러 타입을 주게되면 typescript는 주어진 여러 타입을 객체타입 유니언으로 유추 한다. 예를 들면 아래와 같다.

type A = {...}
type B = {...}

const c = A | B // A type 또는 B type을 가질 수 있다.

1. 유추된 객체타입 유니언

유추된 객체타입유니언이런 객체를 가지는 변수의 타입에 여러 타입을 지정하면 typescript는 상황에 따라 객체 타입을 추론 한다.

아래는 name과 age 속성은 동일하게 가지고 확률에 따라 복권이 당첨되면 getPrice(당첨금액)속성을 가지고 당첨되지 않으면 buyAgain(로또 다시구매 여부)를 가진다고 해보자

const whoByLotto = Math.random() < 0.001 
    ? { name: 'tom', age: 20, getPrice: 1000000000} 
    : { name: 'tom', age: 20, buyAgain: true};

console.log(whoByLotto.getPrice) // undefined일 수 있음
console.log(whoByLotto.buyAgain) // undefined일 수 있음

whoByLotto의 타입을 살펴보면 아래와 같다.

하지만, getPrice와 byAgain 은 선택적으로 가질 수 있는 속성으로 표기되어있다. 이렇게 되면 typescript가 제공하는 언어 서비스인 자동완성을 이용할 때 잘못하여 undefined인 property에 접근하여 잠재적인 오류를 발생시킬 수 있는 경우가 발생할 수 있다. 또한 오류 메세지의 가독성 또한 딱히 좋지 못하다.

이러한 문제는 아래 명시된 객체 타입 유니언 으로 해결할 수 있다.

2. 명시된 객체 타입 유니언

위에 typescript가 유니언 타입 중 하나의 타입으로 추론하게끔 두면 문제라고 하기엔 그렇지만 장점을 살리지 못 하는 것을 확인해봤다. 이러한 경우에는 type alias를 이용하여 없는 속성값에 접근할 수 없게 차단하거나 오류 메세지의 가독성을 높일 수 있다.

type Win = {
    name: string;
    age: number;
    getPrice: number;
}

type Fail = {
    name: string;
    age: number;
    buyAgain: boolean
}

type WhoBuyLotto= Win | Fail;

const whoBuyLotto: WhoBuyLotto = Math.random() < 0.001 
    ? { name: 'tom', age: 20, getPrice: 1000000000} 
    : { name: 'tom', age: 20, buyAgain: true};

console.log(whoBuyLotto.getPrice) // ❌ 속성 검사에 의해 getPrice를 가질 수도 못 가질수도 있음
console.log(whoBuyLotto.buyAgain) // ❌ 이유 동일...

getPrice와 buyAgain property에 접근하려 하면 아래와 같은 오류를 볼 수 있다.

아까와 달리 오류 메세지가 깔끔해졌으며 없을 수 있는 속성에 대해서 속성 검사가 제대로 이루어져 있음을 볼 수 있다. 이렇게 속성 검사를 하면 개발자에게 자동완성 기능또한 없는 속성을 제외하고 보여줌으로써 의도치 않은 없는 값인 undefined에 사용을 차단할 수 있다고 생각한다.

여기까지 공부를 하면서 난 객체타입 유니언은 typescript에게 추론하게끔 두는 것이 아닌 type을 명확하게 정의하는 것이 좋다고 생각한다.

3. 객체 타입 내로잉(object type narrowing)

어떤 변수가 유니언 타입을 가져서 결국 typeguard를 통해서 type을 narrowing해주어야 우리가 typescript를 쓰는 목적을 이룰 수 있다. typeguard에는 여러가지 방법(instanceof, typeof, use in keyword, discriminated Union etc..) 가 있지만 여기서는 in을 이용 한 것과 판별된 유니언(discriminated union) 을 이용한 type narrowing을 살펴볼 것이다.

그리고 property가 존재하는지 아닌지(getPrice가 있다면 Win type일 것이고 아니라면 Fail type일 것이므로) 접근하여 확인하여 type narrowing을 해보려고 시도(👇 아래 코드 참고!!)했지만, 없을 수 있는 속성에 접근 하여 오류가 난다. 생각해보면 whoBuyLotto.getPrice가 type guard로서 작동하기 이전에 속성검사에서 걸리기 때문에 당연히 오류가 나는 것이었다.... 🥲🥲🥲

오류 발생 코드

if (whoBuyLotto.getPrice){ // ❌ Error => Property 'getPrice' does not exist on type 'WhoBuyLotto'.
    // getPrice가 있다면, 여기서 whoBuyLotto는 Win type일 것이다.
}else{
    // getPrice가 없다는 것은 buyAgain이 있다는 것으로 Fail type일 것이다.
}

☁️ in 키워드 사용

in 키워드 사용은 말 그대로 특정 property가 해당 객체 안에 있는지 검사하여 타입을 내로잉하는 것이다.

type Win = {
    name: string;
    age: number;
    getPrice: number;
}

type Fail = {
    name: string;
    age: number;
    buyAgain: boolean
}

type WhoBuyLotto= Win | Fail;

const whoBuyLotto: WhoBuyLotto = Math.random() < 0.001 
    ? { name: 'tom', age: 20, getPrice: 1000000000} 
    : { name: 'tom', age: 20, buyAgain: true};

if ('getPrice' in whoBuyLotto){ // 👍 'in' keyword를 이용한 type narrowing
    console.log(`whoBuyLotto type is Win`)
}else{
    console.log(`whoBuyLotto type is Fail`)
}

getPrice property를 가지고 있다면 Win type으로 type을 narrowing할 수 있음

typescript가 Win type인 것을 알았으니 getPrice property를 자동완성 기능으로 제공할 수 있음

☁️ 판별된 유니언(discriminated union)

판별된 유니언은 객체 타입을 객체 안의 property로 저장하여 객체 속성이 객체의 타입을 나타내게 끔 하는 방법이다. 여기서 객체 타입을 담고 있는 객체 속성을 판별값(대체적으로 type으로 쓴다) 이라고 한다. 이렇게 하면 type(판별값)이라는 property는 모든 객체에 동일하게 가지고 있는 속성으로서 속성검사에 걸리지 않게 되기 때문에 .type으로 property에 접근하여 type guard로서 활용할 수 있다.. 또한 js runtime시에도 타입을 보존할 수 있다는 장점이 존재한다.

type Win = {
    name: string;
    age: number;
    getPrice: number;
    type: 'win' // 👉🏻 타입을 속성으로 담음
}

type Fail = {
    name: string;
    age: number;
    buyAgain: boolean,
    type: 'fail' // 👉🏻 타입을 속성으로 담음
}

type WhoBuyLotto = Win | Fail;

const whoBuyLotto: WhoBuyLotto = Math.random() < 0.001 
    ? { name: 'tom', age: 20, getPrice: 1000000000, type: 'win'} 
    : { name: 'tom', age: 20, buyAgain: true, type: 'fail'};

if (whoBuyLotto.type === 'win'){  // 👍 판별된 유니온을 활용한 type narrowing
    console.log(`whoBuyLotto type is Win`)
}else{
    console.log(`whoBuyLotto type is Fail`)
}

이제 typescript는 whoBuyLotto.type === 'win' 이라면 whoBuyLotto는 Win type 일 것이고 아니라면 Fail type 임을 알 수 있다.

type이 ‘win’이라면 Win type으로 type을 narrowing할 수 있음

typescript가 Win type인 것을 알았으니 Win type에 해당하는 속성들을 자동완성으로 제공할 수 있음

객체 타입을 narrowing하게 위해서 in을 이용한 타입 좁히기와 판별된 유니언을 이용한 타입 좁히기를 알아봤다. 이 두 방법에 대한 내 생각은 아래와 같다.

🤔 in keyword를 이용하여 타입마다 존재하는 property들을 일일히 기억하기 힘들어 다시 타입정의 부분을 볼 바엔 type이라는 속성이 무조건 있으므로 type이라는 property가 있는지 없는지에 대해 생각하지 않아도 되며, 판별값은 win 또는 fail 처럼 리터럴 값으로 존재하므로 typescript가 어떤 값을 가지는지 바로 보여준다. 따라서 판별값에 대한 스스로의 컨벤션을 구축(Ex; Win type 객체에 type: ‘win’과 같이 앞글자는 소문자로 변환)이를 이용하면 type narrowing을 더 빠르게 쉽게 할 수 있을 것 같다. 따라서, 나는 판별된 유니언을 주로 쓸 것 같다. 사실상 이것은 사람에 따라 다를 것 같으니 편한대로 사용하면 될 것 같다.

4. 교차 타입(Intersection Type)

교차 타입은 동일한 property는 그대로 두고 서로 다른 property를 합쳐서 정의한다. 설명이 이해가 덜 간다면 집합에 있어 합집합이라고 생각하면 된다. 교차 타입으로 정의된 타입에 할당하기 위해서는 모든 속성을 담은 값을 할당할 수 있다.

아래와 같이 마법사와 전사 타입이 있다고 하자. 마법사와 전사는 결국 캐릭터 안에 포함되므로 캐릭터는 모든 속성들을 포함한다.

type Magician = {
    name: string;
    fireball: boolean;
}

type Warrior = {
    name: string;
    sword: boolean;
}

type Character = Magician & Warrior;

const combineMagicianWarrior: Character = {
    name: 'tom',
    fireball: true,
    sword: true
}

combineMagicianWarrior의 type은 { name: string; fireball: boolean; sword: boolean } 이 될 것이다. 동일한 속성의 name도 있으며 Magician에 없는 sword property와 Warrior에 없는 fireball property를 둘 다 가짐을 알 수 있다.

그리고 아래와 같이👇 교차타입은 유니언 타입과 같이 작성할 수 있다.

type Character = { name: string } & ({ fireball: boolean } | { sword: boolean })

const magicianOrWarrior: Character = {
    name: 'magician name',
    fireball: true
}

궁금해서 이것저것 시도해보던 중 아래 코드에서 이상함을 느꼈다…..😱😱😱😱😱

type Character = { name: string } & ({ fireball: boolean } | { sword: boolean })

const magicianOrWarrior: Character = {
    name: 'magician name',
    fireball: true,
    sword: true // ⭕️ ????
}

멘붕이다….. 흠….;; 마법사는 검으로 안 싸우는데… ㅜㅜ

결국 type narrowing이 안 된다….. 이는 typescript를 쓰는 이유가 없다…

따라서 이걸 어떻게 해결할까 고민하다가 판별 유니온을 이용하여 각 객체마다 구별할 수 있는 판별값 을 넣어본다면??? 이란 생각이 들어서 아래와 같이 코드를 수정했다.

type Character = { name: string } 
    & ({ 
        fireball: boolean;
        type: 'magician'
    } 
    | { 
        sword: boolean,
        type: 'warrior' 
    })

const magicianOrWarrior: Character = {
    name: 'magician name',
    fireball: true,
		type: 'magician',
    sword: true // ❌
}

판별 유니언을 이용했더니 typescript가 하나의 타입으로 좁혀 추론한 것을 알 수 있다. 따라서 없는 sword property에 대해서 불만을 토로한다…

그리고 교차 타입을 쓰면 진짜 오류를 읽기 힘들다. 궁수라는 캐릭터를 추가하고 궁수 타입에 없는 sword property를 넣어 극단적인 예시를 만들어봤다.

const magicianOrWarrior:{ name: string } 
    & ({ 
        fireball: boolean;
    } 
    | { 
        sword: boolean,
    }
    | {
        arrow: boolean,
    }) = {
        name: 'best archor',
        noProperty: false  // ❌
    }

noProperty: false에 대한 오류는 아래와 같이 나온다… 빨간색 박스를 주시해보자

만약에 다른 캐릭터를 100개를 더 넣는다면 | 가 101개로 이루어진 아주 긴 오류메세지를 빨간색 박스 안에서 볼 수 있을 것 같다.

🚀 마치며

객체에 타입스크립트가 어떻게 동작하는 지 알아봤는데 뭔가 스스로에게 질문이 되게 많아졌다…

과연 어떤 방식으로 typescript로 쓰는 것이 좋은 건지 모르겠어서 여기저기 찾아보면 차이점 정도만 나온다. 예를 들어서 객체를 type narrowing할 때 in keyword를 이용한 typeguard를 사용할 지 판별 유니언을 이용하여 타입을 좁힐지에 대한 것들이다. 따라서, 내가 공부하면서 이것이 더 좋겠다 등 배운 것에 대해서 나열해 봄으로써 끝을 마친다.

  • 변수에 타입을 선언한 위치에 "객체 리터럴 값"을 동시에 할당하여 "초과 속성 검사" 대상에 포함시키는 것이 좋을 것 같다. ⇒ typescript가 초과속성검사를 하게끔 유도!!!
  • 객체는 타입 단언(type assertion) 보다는 타입 별칭을 이용하여 타입을 파악하기 쉽도록 하며 오류에 대한 가독성이 좋도록 출력하는 것이 좋다. ⇒ type alias 또는 interface를 이용!!
  • 객체가 union type으로 type이 지정될 시 type alias를 이용하여 type을 지정하여 내로잉 시 type이 명확하게 지정되게 함이 좋다.. ⇒ 위의 1. 유추된 객체 타입 유니언에서 타입 단언으로 인해 발생할 수 있는 문제점을 제거
  • 교차 타입이 유용해 보이지만 남용하여 오류가 발생시 타입정보에 대한 파악이 매우 힘들 것 같아 써야될 상황에서만 쓰기로 함.
  • 객체 typeguard시 생길 수 있는 불편한 점(위의 판별된 유니온에 있음)을 해결하기 위해 판별된 유니언(discriminated union)을 이용한 typeguard를 많이 쓸 것이다.

📚 참고

excess property check(초과속성검사)
learning-typescript page
learning-typescript-book

profile
step by step

0개의 댓글