React,  Development,  Article
Typescript로 nested object type 지정하기
깊고 깊은 TypeScript 사이로
2023. 7. 17.2023. 7. 17.

배경 설명

thumbnail

TypeScript는 분명히 코드의 안정성을 올려주고, 발생할 수 많은 버그들을 에디터에서 사전에 잡아낼 수 있죠. 저도 그 덕을 톡톡히 봤고 만족스럽게 써오고 있었습니다. 심지어 대부분의 라이브러리들이 TypeScript를 본격적으로 지원하기 때문에 타입만 적절하게 import 해오면 편하게 사용할 수 있었습니다.

그렇게 라이브러리들의 타입들만 사용하면서 큰 문제 없이 사용했기에, “음, TypeScript 별거 없구만” 이라고 생각했습니다.

하지만 그게 아니였죠. 그렇게 쉬운 친구는 아니었어요.

본 게시물에서는 복잡하고 Nested한 Object에 Type을 넣은 저의 삽질 경험을 작성합니다.

상황 설정



여기 새로 개장한 슈타인-마트가 있습니다.

개발자 출신인 슈타인(본인)은 일일 일지를 자동화하여 기록하려 합니다.

인력 관리

그날 출근한 사람들을 관리하는 객체를 선언해서 정리하면 되겠군요

{
	출근 직원: ['일론 머스크','마크 저커버그','빌 게이츠','팀 쿡'],
	관리인: '슈타인'
}

품목 관리

당연히 마트에서 판매할 품목도 관리해야겠죠. 아직 개장한지 얼마 안되어서 식료품과 전자제품만 팝니다.

마트: {
	인력: {
		출근 직원: ['일론 머스크','마크 저커버그','빌 게이츠','팀 쿡'],
		관리인: '슈타인'
	},
	품목:{
		식료품:['상추','콜라'],
		전자제품:['스마트폰']
	}
}

모든 품목은 상표와 가격을 가질테니 추가해줍니다. 그리고 각 카테고리를 설명해주는 문구도 있으면 좋겠네요.

마트: {
	인력: {
		출근 직원: ['일론 머스크','마크 저커버그','빌 게이츠','팀 쿡'],
		관리인: '슈타인'
	},
	품목:{
		식료품:{
			상추:{ // PER
				상표: 신선 상추,
				가격:{
					원가: 1000,
					판매가: 1200,
				},
			},
			콜라:{
				상표: 코카콜라,
				가격:{
					원가: 2000,
					판매가: 2400,
				},
			}
		},
		전자제품:{
			핸드폰:{
				상표: 아이폰 14,
				가격:{
					원가: 140만원,
					판매가: 160만원,
				},
			}
		}
	}
}

자, 이제 위의 객체에 타입을 넣어봅시다!

타입 추가

마트

탑-다운 방식으로, 마트 부터 타입을 정의해보죠.

type T마트 = {
  인력:T인력,
  품목:T품목
}

인력 관리

type T인력 = {
  출근 직원: string[],
  관리인: string
}

품목

먼저 눈에 보이는 대로 타입을 정의해 봅니다.

type T품목={
  식료품:{
    상추:{
      상표:string,
      가격:{
        원가:number,
        판매가:number
      }
    },
    콜라:{
      상표:string,
      가격:{
        원가:number,
        판매가:number
      }
    }
  },
  전자제품:{
    핸드폰:{
      상표:string,
      가격:{
        원가:number,
        판매가:number
      }
    }
  }
}

제품명, 가격이 중복 되니 따로 타입을 정의하고 정리해봅시다

type T제품기본정보 = {
  상표:string,
  가격:{
    원가:number,
    판매가:number
  }
}

type T품목={
  식료품:{
    상추:T제품기본정보,
    콜라:T제품기본정보,
  },
  전자제품:{
    핸드폰:T제품기본정보,
  }
}

예외 사항(제품별 특이사항)

“아잇 싸장님. 이러면 제품별로 다르게 가져야하는 정보들을 기록할 수가 없잖아요!”

아뿔싸. 직원 ‘팀쿡’에게 혼 났습니다.

‘팀쿡’은 필요한 정보를 추가로 작성해줍니다.


품목:{ 
	식료품:{
		상추:{
			상표: 신선 상추,
			가격:{
				원가: 1000,
				판매가: 1200,
			},
			유통기한: 2023-07-10
			신선도: 3,
		},
		콜라:{
			상표: 코카콜라,
			가격:{
				원가: 2000,
				판매가: 2400,
			},
			유통기한: 2024-03-01,
			용량: 2L
		}
	},
	전자제품:{
		핸드폰:{
			상표: 아이폰 14,
			가격:{
				원가: 140만원,
				판매가: 160만원,
			},
			충전기 제공 여부: false
		}
	}
}

식료품 전체에는 유통기한이, 그 중에서 상추는 신선도를, 콜라는 용량이 추가 됐네요. 그리고 핸드폰에는 충전기 제공 여부가 생겼습니다.

타입을 정의해보겠습니다

T식료품

image.png

image(1).png

그럴싸 하다고 생각했지만 안되는군요. 뭔가 클래스의 상속처럼 작동하면 좋겠는데요. interface의 extends로 한번 구현해볼까요?

image(2).png

image(3).png

이것도 안되네요.

Mapped Type으로 정의하면, 해당 key의 조건을 만족하는 모든 value값이 단 하나로 고정되어 버립니다. 여기서 반복작업은 불가피할 것 같습니다. 조금 귀찮더라도 Type을 모두 명시해줍시다.

type T품목={
  식료품:T식료품,
  전자제품:T전자제품
}

type T식료품기본정보={
  유통기한:Date
} & T제품기본정보

type T식료품={
  상추: T식료품기본정보 & {신선도:number},
  콜라: T식료품기본정보 & {용량:number}
}

type T전자제품기본정보={
  
}&T제품기본정보

type T전자제품={
  핸드폰:T전자제품기본정보 & {'충전기 제공 여부':boolean},
}

image(4).png

콜라에 신선도가 없다는걸 잘 추론해냅니다.

제품명 받아오기(문제 상황)

품목 이름과, 제품 이름을 작성하면 상표를 가져오는 함수를 만들어봤습니다.

function get제품명
	<A extends keyof T마트["품목"]>
	(품목이름:A, 제품이름: keyof T마트["품목"][A]){
  return 마트["품목"][품목이름][제품이름]["상표"]
}

image(8).png

이런, 왠지 모르겠지만 인자로 받아온 이름들을 넣었는데, unknown으로 타입이 인식되어서 [”상표”]가 없다는 에러가 발생하네요.

image(9).png

하지만 분명히 type은 제대로 추론되고 있고

image(7).png

이렇게 값을 작성해보면 문제가 없는데요. 하나씩 뜯어봅시다.

function get상표<A extends keyof T마트["품목"]>(품목이름:A, 제품이름: keyof T마트["품목"][A]){
  let 품목:T마트["품목"][A] // T식료품 | T전자제품
  let keys:keyof T마트["품목"][A] // 'never'
  
  return 마트["품목"][품목이름][제품이름]["상표"]
}

먼저 품목T마트["품목"][A]T식료품 | T전자제품 입니다. 여기까지는 예상하기 쉽습니다. 하지만 keyskeyof T마트["품목"][A]**never** 가 나오게 됩니다. 왜냐하면 keyof는 union type에 대해서는 중복적으로 겹치는 key값만 union으로 만들기 때문입니다.

image(10).png

이 부분이 문제였습니다! type은 런타임에 다이나믹하게 결정되는 것이 아니라 정말 보수적으로, 확신할 수 있는 것들로만 구성되는 것을 확인할 수 있습니다. 그러면 어떻게 이 문제를 해결할 수 있을까요?

제품명 받아오기(해결 방법)

TypeScript playground

결국 중간의 key값이 never가 될 수 밖에 없으므로, 순차적으로 따라가면 타입을 잃어버리게 됩니다. 따라서 저는 타입 상단부에, 하단부의 정보를 미리 기입해서, 타입스크립트가 하위 타입의 내용을 추론할 수 있도록 했습니다.

type T식료품 = {
  상추: T식료품기본정보 & {신선도:number},
  콜라: T식료품기본정보 & {용량:number},
  [key:string]: T식료품기본정보
} 

type T전자제품={
  핸드폰: T전자제품기본정보 & {'충전기 제공 여부':boolean},
  [key:string]: T전자제품기본정보
}

T식료품T전자제품[key:string]: T식료품기본정보 , [key:string]: T전자제품기본정보 를 추가했습니다. 이를 통해, T식료품, T전자제품이 가지는 value들은 key가 어떤 값이건, 무조건 T전자제품기본정보(T식료품기본정보)를 가진다는 것을 알았습니다. 중간에 key값이 never가 들어오더라도 말이죠!

image(11).png

이제 [”상표”]에도 에러가 발생하지 않고

image(12).png

없는 키를 넣으면 에러도 제대로 출력합니다. 그럼 이제 다 됐나요?

아닙니다. 갑자기 위에서 작성한 선언문에서 에러가 발생하네요. (오, 제발)

image(14).png

image(13).png

신선도는 T제품기본정보 & T식료품기본정보에 없다고 합니다.

T식료품[”상추”]에는 분명히 신선도를 추가해놨는데요! 아까 설정한 Mapped Type이 value값이 강하게 설정되나 봅니다. 따라서 다른 값들도 올 수 있도록 제한을 풀어줍니다.

type T식료품 = {
  상추: T식료품기본정보 & {신선도:number},
  콜라: T식료품기본정보 & {용량:number},
  [key:string]: T식료품기본정보 & {[others:string]:any}
} 

type T전자제품={
  핸드폰:T전자제품기본정보 & {'충전기 제공 여부':boolean},
  [key:string]:T전자제품기본정보 & {[others:string]:any}
}

T식료품T전자제품[others:string]: any 를 추가했습니다. 이제 T식료품기본정보(T전자제품기본정보)외의 정보도 올 수 있습니다.

image(15).png

신선도에서 에러는 더 이상 발생하지 않고, 없는키는 확실히 에러로 걸러내는 모습입니다.

이제 슈타인-마트는 매일 정보를 실수없이 기록하고, 사용할 수 있게 됐습니다!

정리

전체 코드와 구현된 기능들

TypeScript Playground에서 보기

const 마트:T마트={
  인력:{
  "출근 직원": ['일론 머스크','마크 저커버그','빌 게이츠','팀 쿡'],
  관리인: "슈타인"
  },
  품목:{
		식료품:{
			상추:{ 
				상표: '신선 상추',
				가격:{
					원가: 1000,
					판매가: 1200,
				},
        유통기한: new Date(2023,6,10),
        신선도: 3,
			},
			콜라:{
				상표: '코카콜라',
				가격:{
					원가: 2000,
					판매가: 2400,
				},
        유통기한: new Date(2024,2,1),
        용량: 2
			},
		},
		전자제품:{
			핸드폰:{
				상표: '아이폰 14',
				가격:{
					원가: 140,
					판매가: 160,
				},
        '충전기 제공 여부':false
			}
		}
	}
}

// types

type T마트 = {
  인력:T인력,
  품목:T품목
}

type T인력 = {
  '출근 직원': string[],
  관리인: string
}

type T품목={
  식료품:T식료품,
  전자제품:T전자제품
}

type T식료품 = {
  상추: T식료품기본정보 & {신선도:number},
  콜라: T식료품기본정보 & {용량:number},
  [key:string]: T식료품기본정보 & {[others:string]:any}
}

type T전자제품={
  핸드폰:T전자제품기본정보 & {'충전기 제공 여부':boolean},
  [key:string]:T전자제품기본정보 & {[others:string]:any}
}

type T제품기본정보 = {
  상표:string,
  가격:{
    원가:number,
    판매가:number
  }
}

type T식료품기본정보={
  유통기한:Date
} & T제품기본정보

type T전자제품기본정보={
}&T제품기본정보
  1. T마트 타입이 정의되었습니다. 알맞지 않은 값들을 넣거나, 사용(접근)하면 에러를 발생시킵니다
  2. 모든 제품에 기본 키 값들을 주입할 수 있습니다(T기본제품정보)
  3. 품목별로 개별적으로 기본 키값들을 주입할 수 있습니다(T식료품기본정보, T전자제품기본정보)
  4. 중복적으로 작성해야하는 코드가 매우 최소화 되었습니다.

개선사항이 필요한 부분

품목에서 유효하지 않는 키값 사용가능

image(16).png

식료품은 `상추`가 정의되지 않았다고 에러를 발생시킬 수는 있습니다.

하지만 상추, 콜라가 정의되어 있다면, 그 외의 다른 없는 키가 작성되어도 에러를 발생시키지 않습니다.

image(17).png

이것은 제품명 받아오기(해결 방법) 때 구현한 {[others:string]:any} 때문입니다.

type T식료품 = {
  상추: T식료품기본정보 & {신선도:number},
  콜라: T식료품기본정보 & {용량:number},
  [key:string]: T식료품기본정보 & {[others:string]:any} // <- 너무 조건이 약해짐
} 

type T전자제품={
  핸드폰:T전자제품기본정보 & {'충전기 제공 여부':boolean},
  [key:string]:T전자제품기본정보 & {[others:string]:any} // <- 너무 조건이 약해짐
}

Mapped Type을 사용하면 발생할 수 있는 상황중 하나입니다. 하지만 하위 정보들이 어떤게 올지 알 수 없는 상황에서는 any를 사용할 수 밖에 없고, 이에 제약을 제대로 주려면, 또 이를 위한 key-type을 정의한 타입(제네릭)이 필요하게 됩니다. 그렇게 되면

  1. 사실상 중복으로 코드들을 작성하게 되는데, 중복을 줄이기 위해 이렇게 작업한 것이 의미가 없어지게 됩니다.
  2. keyof로 접근하게 nested한 객체에 접근하면 대부분 never가 됩니다.(중복으로 겹치는 타입을 사용하는 상황이 아니라면). 따라서 이 문제는 모든 타입을 일일이 작성한다고 해결되지 않습니다.

따라서 저는 해당 버그만을 known-bug로 정의하고 사용하기로 정했습니다. 이를 해결할 방법을 찾기위해 며칠을 사용했지만, 결국 이 방법이 가장 많은 요구사항을 만족해 주더라고요.

결론

실제 타임라인

처음 이 문제를 접했을 때. 아니, 접하기 전부터 왠지모르게 복잡한 문제가 발생할 것 같은 느낌을 받았습니다. 그 예상은 적중했고, 일주일 정도를 이 문제를 붙잡고 낑낑거렸네요.

초반 2~3일 동안은 제가 알고 있는 지식(과 GPT의 도움)으로 해결하려고 했고, 계속 생각지도 못한 곳에서 문제가 터져서 “아, 내가 타입스크립트를 너무 모르는 채로 사용만 했지, 직접 작성할 만큼의 지식이 없구나” 라는걸 깨달았습니다.

그리고는 타입스크립트를 공부하는 방향으로 전환해서 문제를 해결하려 했습니다. 다음은 도움을 받았던 사이트들입니다.

  1. type-challenges
    1. 타입스크립트 문제를 풀어볼 수 있는 레포지토리입니다. TypeScript Playground와 연동되어서 별도의 환경 설정없이 바로 도전할 수 있씁니다. 또한 다양한 solution들도 github에서 볼 수 있습니다. 난이도별로 문제가 분류되어 있어서 실력 올리기가 좋습니다.
    2. typescript의 경우, 여러가지의 해법이 있을 수 있는데, issue에서 사람들이 대화하는 과정을 보면 여러가지 접근법을 볼 수 있습니다.
    3. 추천합니다.
  2. typescript-exercises
    1. type-challenges와 비슷합니다만, 조금 더 구체적인 상황을 주고 문제를 해결합니다. 문제 숫자가 많지 않으며, bonus 문제들이 있는 경우가 있는데, 그러면 일반 문제의 solution을 볼 수 없습니다(…). 이 사이트를 초반에 봤는데, 난이도가 좀 있다고 느꼈습니다.
  3. TypeScript 공신 문서
    1. 공식 문서입니다. 생각보다 디테일한 상황은 잘 설명해주지 않아서 생각보다 큰 도움은 얻지 못한 느낌입니다(제 실력이 문제일 수도 있습니다). 그리고 왜인지 모르겠지만, 다른 공식문서나 원서보다 참 잘 안읽힌다는 느낌을 받았습니다(역시나 제 실력이 문제일 수도 있습니다).
    2. 그래도 stackoverflow의 답변들이 대부분 “공식문서를 읽어봐라!” 라고 하니, 다시 한번 보려고 합니다.
  4. TypeScript Deep Dive
    1. 공식문서보다 조금 더 딥하고, 친절하게 알려준다는 느낌을 받았습니다.
    2. 처음에는 JS 문법에 대한 설명들이 적힌 페이지만 줄창 봐서, type에 대해서 깊게 다루지 않는 줄 알았습니다만, TypeScript’s Type System이란 카테고리가 따로 있더군요… 공식 문서보다도 먼저 살펴 보려고 합니다.

정말 제가 모르던 문법이 많았습니다. 그렇게 많은 문법을 습득하면서, “아 이거면 되겠다!!” 싶은 것들을 하나씩 적용했지만, 대부분 바로 문제를 해결시켜주지 못했습니다. 결국 typescript 해석 자체를 좀 더 엄밀하게 하게 되면서 문제를 해결하게 됐습니다.

느낀점

저는 클래스를 생각하면서 typescript를 사용했는데, 그 부분에서 많이 헤맸습니다. 타입을 클래스와 유사하지만, 완전히 다른 개념이라고 생각하니 이해가 좀 더 쉬웠습니다.

그리고 보면 볼수록 타입스크립트… “정돈이 안되어 있다” 라는 느낌을 받긴 했습니다(정석적인 해결이라기 보다, tricky한 해결법이 많은 느낌입니다). 애초에 언어 레벨에서 시작된 타입이 아니어서 그럴까요? 물론. 물론 제 실력이 문제일 수 있습니다…

그렇기에 “타입스크립트 싫어!”가 아니라, “그러니까 더 공부하고, 경험해 봐야겠다” 라는 생각이 강하게 들었습니다. 타입을 잘 못 설정한걸 수도 있지만, 데이터의 사용이 문제일 수도, 아니면 애초에 이런 구조를 사용하면 안되는 걸 수도 있기 때문입니다. 분명 더 큰 프로젝트를 진행하면, 이보다 더 복잡한 데이터를 사용할 수 밖에 없을텐데, 그 전에 준비를 하고 싶기도 하고요.

여러모로 성장에 큰 자극이 된 문제였습니다.

번외

Mapped Type중, key의 제약이 너무 넓어지는 경우.

참고로 비슷하지만 다른 상황이 있어서 추가로 기록합니다.

Index Signatures

interface NestedCSS {
  color?: string;
  [selector: string]: string | NestedCSS | undefined;
}

const example: NestedCSS = {
  color: 'red',
  '.subclass': {
    color: 'blue'
  }
}

const failsSilently: NestedCSS = {
  colour: 'red', // No error as `colour` is a valid string selector
}

이런 경우에는 1depth를 더 내림으로써 해결할 수 있습니다.

interface NestedCSS {
  color?: string;
  nest?: {
    [selector: string]: NestedCSS;
  }
}

const example: NestedCSS = {
  color: 'red',
  nest: {
    '.subclass': {
      color: 'blue'
    }
  }
}

const failsSilently: NestedCSS = {
  colour: 'red', // TS Error: unknown property `colour`
}