그렇게 라이브러리들의 타입들만 사용하면서 큰 문제 없이 사용했기에, “음, 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
}
}
}
식료품 전체에는 유통기한이, 그 중에서 상추는 신선도를, 콜라는 용량이 추가 됐네요. 그리고 핸드폰에는 충전기 제공 여부가 생겼습니다.
타입을 정의해보겠습니다
그럴싸 하다고 생각했지만 안되는군요. 뭔가 클래스의 상속처럼 작동하면 좋겠는데요. interface의 extends로 한번 구현해볼까요?
이것도 안되네요.
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},
}
콜라
에 신선도가 없다는걸 잘 추론해냅니다.
품목 이름과, 제품 이름을 작성하면 상표를 가져오는 함수를 만들어봤습니다.
function get제품명
<A extends keyof T마트["품목"]>
(품목이름:A, 제품이름: keyof T마트["품목"][A]){
return 마트["품목"][품목이름][제품이름]["상표"]
}
이런, 왠지 모르겠지만 인자로 받아온 이름들을 넣었는데, unknown
으로 타입이 인식되어서 [”상표”]
가 없다는 에러가 발생하네요.
하지만 분명히 type은 제대로 추론되고 있고
이렇게 값을 작성해보면 문제가 없는데요. 하나씩 뜯어봅시다.
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전자제품
입니다. 여기까지는 예상하기 쉽습니다. 하지만 keys
는 keyof T마트["품목"][A]
로 **never**
가 나오게 됩니다. 왜냐하면 keyof
는 union type에 대해서는 중복적으로 겹치는 key값만 union으로 만들기 때문입니다.
이 부분이 문제였습니다! type은 런타임에 다이나믹하게 결정되는 것이 아니라 정말 보수적으로, 확신할 수 있는 것들로만 구성되는 것을 확인할 수 있습니다. 그러면 어떻게 이 문제를 해결할 수 있을까요?
결국 중간의 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
가 들어오더라도 말이죠!
이제 [”상표”]에도 에러가 발생하지 않고
없는 키를 넣으면 에러도 제대로 출력합니다. 그럼 이제 다 됐나요?
아닙니다. 갑자기 위에서 작성한 선언문에서 에러가 발생하네요. (오, 제발)
신선도는 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전자제품기본정보)
외의 정보도 올 수 있습니다.
신선도
에서 에러는 더 이상 발생하지 않고, 없는키
는 확실히 에러로 걸러내는 모습입니다.
이제 슈타인-마트
는 매일 정보를 실수없이 기록하고, 사용할 수 있게 됐습니다!
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제품기본정보
하지만 상추
, 콜라
가 정의되어 있다면, 그 외의 다른 없는 키가 작성되어도 에러를 발생시키지 않습니다.
이것은 제품명 받아오기(해결 방법) 때 구현한 {[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을 정의한 타입(제네릭)이 필요하게 됩니다. 그렇게 되면
never
가 됩니다.(중복으로 겹치는 타입을 사용하는 상황이 아니라면). 따라서 이 문제는 모든 타입을 일일이 작성한다고 해결되지 않습니다.따라서 저는 해당 버그만을 known-bug로 정의하고 사용하기로 정했습니다. 이를 해결할 방법을 찾기위해 며칠을 사용했지만, 결국 이 방법이 가장 많은 요구사항을 만족해 주더라고요.
처음 이 문제를 접했을 때. 아니, 접하기 전부터 왠지모르게 복잡한 문제가 발생할 것 같은 느낌을 받았습니다. 그 예상은 적중했고, 일주일 정도를 이 문제를 붙잡고 낑낑거렸네요.
초반 2~3일 동안은 제가 알고 있는 지식(과 GPT의 도움)으로 해결하려고 했고, 계속 생각지도 못한 곳에서 문제가 터져서 “아, 내가 타입스크립트를 너무 모르는 채로 사용만 했지, 직접 작성할 만큼의 지식이 없구나” 라는걸 깨달았습니다.
그리고는 타입스크립트를 공부하는 방향으로 전환해서 문제를 해결하려 했습니다. 다음은 도움을 받았던 사이트들입니다.
정말 제가 모르던 문법이 많았습니다. 그렇게 많은 문법을 습득하면서, “아 이거면 되겠다!!” 싶은 것들을 하나씩 적용했지만, 대부분 바로 문제를 해결시켜주지 못했습니다. 결국 typescript 해석 자체를 좀 더 엄밀하게 하게 되면서 문제를 해결하게 됐습니다.
저는 클래스를 생각하면서 typescript를 사용했는데, 그 부분에서 많이 헤맸습니다. 타입을 클래스와 유사하지만, 완전히 다른 개념이라고 생각하니 이해가 좀 더 쉬웠습니다.
그리고 보면 볼수록 타입스크립트… “정돈이 안되어 있다” 라는 느낌을 받긴 했습니다(정석적인 해결이라기 보다, tricky한 해결법이 많은 느낌입니다). 애초에 언어 레벨에서 시작된 타입이 아니어서 그럴까요? 물론. 물론 제 실력이 문제일 수 있습니다…
그렇기에 “타입스크립트 싫어!”가 아니라, “그러니까 더 공부하고, 경험해 봐야겠다” 라는 생각이 강하게 들었습니다. 타입을 잘 못 설정한걸 수도 있지만, 데이터의 사용이 문제일 수도, 아니면 애초에 이런 구조를 사용하면 안되는 걸 수도 있기 때문입니다. 분명 더 큰 프로젝트를 진행하면, 이보다 더 복잡한 데이터를 사용할 수 밖에 없을텐데, 그 전에 준비를 하고 싶기도 하고요.
여러모로 성장에 큰 자극이 된 문제였습니다.
참고로 비슷하지만 다른 상황이 있어서 추가로 기록합니다.
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`
}