
메인 페이지의 도시 카드 모달에서 정적 데이터로 저장해 놓은 도시 기본 정보(항공, 시차, 비자, 여권 만료 조건, 전압)를 보여주기 위해서 타입을 정의하고 함수를 생성하였다.
countryBasicInfo.ts : 도시 기본 정보 정적 데이터
export const COUNTRY_BASIC_INFO: CountryCityInfo = {
대한민국: {
항공: "없음",
시차: "없음",
비자: "없음",
여권만료조건: "없음",
전압: "220V 60Hz / Type C, F",
변환: "불필요",
},
일본: {
도쿄: {
항공: "직항 2시간 05분 ~ 2시간 25분",
시차: "없음 (UTC+9)",
비자: "90일 무비자 체류 가능",
여권만료조건: "일본 체류 기간 동안 유효",
전압: "100V 50Hz / Type A, B",
변환: "필수",
},
삿포로: {
항공: "직항 2시간 30분 ~ 3시간 00분",
시차: "없음 (UTC+9)",
비자: "90일 무비자 체류 가능",
여권만료조건: "일본 체류 기간 동안 유효",
전압: "100V 50Hz / Type A, B",
변환: "필수",
},
오사카: {
항공: "직항 1시간 45분 ~ 2시간 05분",
시차: "없음 (UTC+9)",
비자: "90일 무비자 체류 가능",
여권만료조건: "일본 체류 기간 동안 유효",
전압: "100V 60Hz / Type A, B",
변환: "필수",
},
후쿠오카: {
항공: "직항 1시간 20분 ~ 1시간 40분",
시차: "없음 (UTC+9)",
비자: "90일 무비자 체류 가능",
여권만료조건: "일본 체류 기간 동안 유효",
전압: "100V 60Hz / Type A, B",
변환: "필수",
},
},
// 내용 생략
};
cityInfo.type.ts
// 공통 정보 타입
export type CityInfo = {
항공: string;
시차: string;
비자: string;
여권만료조건: string;
전압: string;
변환: "필수" | "선택" | "불필요";
};
export type NestedCityInfo = {
[cityName: string]: CityInfo;
};
// 전체 국가 정보 타입
export type CountryCityInfo = {
[countryName: string]: CityInfo | NestedCityInfo;
};
getCityInfo.ts : 도시 이름과 키를 기반으로 해당 도시 기본 정보를 안전하게 추출하는 함수
export const getCityInfo = <K extends keyof CityInfo>(
city: string, // 조회할 도시 이름 (예: "서울", "로마")
countryData: CityInfo | NestedCityInfo, // 도시 정보를 담고 있는 객체 (단일 계층 또는 중첩 구조)
key: K // 조회할 정보의 키 (예: "항공", "비자")
): CityInfo[K] | null => { // 반환 타입은 CityInfo의 키에 해당하는 값 또는 null
// countryData가 객체이고, "항공"이라는 키가 없다면 => NestedCityInfo 구조로 간주함
if (typeof countryData === "object" && !("항공" in countryData)) {
// city가 존재하고, countryData에 해당 도시(city)의 정보가 있을 경우,
// 해당 도시의 key에 해당하는 정보를 반환함
if (city && countryData[city]) return countryData[city][key];
else return null; // 도시 정보가 없으면 null을 반환함
}
// "비자"라는 키가 존재한다면 => 단일 CityInfo 구조로 간주함
// 단일 CityInfo로 간주하고 key 값을 반환함
if ("비자" in countryData) return (countryData as CityInfo)[key];
return null; // 위 조건에 모두 해당하지 않으면 null을 반환함
}
위 함수에서 제네릭(Generic), 타입 가드, 타입 단언 등 다양한 타입스크립트의 개념이 나오는데, 함수에 등장하는 타입스크립트 개념들에 대해서 정리해보기로 했다.
<K extends keyof CityInfo>타입스크립트에서 <K extends keyof CityInfo>는 제네릭(Generic)을 사용할 때 타입을 제한하는 방법 중 하나이다.
<K>는 제네릭 타입 파라미터
제네릭은 재사용 가능한 유연한 함수(또는 타입)를 정의할 수 있게 해준다.
function identity<T>(arg: T): T {
return arg;
}
이처럼 T가 들어간 곳에 어떤 타입이든 넣을 수 있다.
keyof CityInfo
이건 CityInfo 타입의 key들을 union type으로 추출하는 TypeScript 연산자이다.
따라서 keyof CityInfo는 다음과 같은 유니언 타입이 된다.
"항공" | "시차" | "비자" | "여권만료조건" | "전압" | "변환"
<K extends keyof CityInfo>
이제 이 전체는 "K는 CityInfo의 key 중 하나여야 한다"는 의미이다.
즉, K는 "항공", "시차", "비자", "여권만료조건", "전압", "변환" 중 하나가 될 수 있다는 제한을 건 것이다.
key 파라미터가 CityInfo에 존재하는 key가 아닌 값이 들어오는 걸 타입스크립트가 막아줘서 타입 안정성이 높아진다.CityInfo[K]
CityInfo[K]는 인덱스드 액세스 타입(indexed access type)이다.
예를 들어,
K = "항공"이면 CityInfo[K]는 string 타입K = "변환"이면 CityInfo[K]는 유니언 타입즉, key에 해당하는 값의 타입을 자동으로 추론해서 반환 타입으로 사용한다.
최종 요약
export const getCityInfo = <K extends keyof CityInfo>(
city: string,
countryData: CityInfo | NestedCityInfo,
key: K
): CityInfo[K] | null
K는 CityInfo의 key 중 하나여야 함CityInfo[K], 즉 그 키에 해당하는 타입(string, union)임"비자" in countryData"비자" in countryData는 JavaScript/TypeScript의 in 연산자를 사용한 것으로, 해당 객체(countryData)에 "비자"라는 속성(key)이 존재하는지를 확인하는 조건문이다.
기본 문법
"key" in object
"key" : 문자열 형태의 속성 이름object : 검사할 대상 객체true 또는 false 반환"비자" in countryData는 무슨 뜻일까?
if ("비자" in countryData) {
// countryData는 CityInfo라고 간주해도 안전함
}
countryData가 "비자"라는 속성을 직접 가지고 있다면 true를 반환한다.countryData가 다음과 같이 생겼다면,{
항공: "직항 1시간 45분 ~ 2시간 00분",
시차: "한국 기준 -1시간 (UTC+8)",
비자: "30일 무비자 체류 가능",
여권만료조건: "중국 도착 시점 기준 유효",
전압: "220V 50Hz / Type A, C, I",
변환: "선택",
}이 객체에는 "비자"라는 key가 있으므로 true이다.왜 이렇게 쓸까?
이 코드는 CityInfo와 NestedCityInfo를 구분하기 위해 사용되었다.
CityInfo : 도시별 여행 정보를 담은 평평한 구조NestedCityInfo : 도시 이름을 key로 하고 그 값이 CityInfo인 중첩 구조export type CityInfo = {
항공: string;
시차: string;
비자: string;
여권만료조건: string;
전압: string;
변환: "필수" | "선택" | "불필요";
};
export type NestedCityInfo = {
[cityName: string]: CityInfo;
};
"비자"라는 key가 있으면 CityInfo라고 판단할 수 있음"비자"가 없다면 NestedCityInfo로 간주할 수 있음"비자" 대신 "항공", "시차", "여권만료조건" 등 CityInfo에 존재하는 어떤 속성을 사용해도 동작은 같음 (단지 "비자"가 대표적인 필드로 선택된 것임)최종 정리
"비자" in countryData는
countryData 객체가 "비자"라는 속성을 가지고 있는지 검사하는 것CityInfo 타입인지(NestedCityInfo가 아닌지) 구분할 수 있음countryData as CityInfo처럼 안전하게 접근 가능countryData as CityInfocountryData as CityInfo는 타입스크립트의 타입 단언(Type Assertion) 문법이다.
타입 단언(Type Assertion)이란?
타입 단언은 타입스크립트에게 "개발자인 내가 이 값의 타입을 정확히 알고 있으니, 이 타입으로 취급해줘!"라고 명시적으로 알려주는 것이다.
value as TargetTypeconst value = "123" as string;왜 as를 써야 할까?
countryData는 다음처럼 두 가지 타입 중 하나로 선언되어 있다.
countryData: CityInfo | NestedCityInfo
이 상태에서 countryData["비자"]처럼 바로 접근하려고 하면, TypeScript는 타입이 아직 확정되지 않았다고 판단해 안전하게 접근할 수 없다고 막는다.
그래서 개발자가 직접 타입을 확정해주는 것이다.
(countryData as CityInfo)["비자"]
➡️ "이 시점에서는 countryData가 CityInfo라는 걸 내가 확실히 알고 있다"고 단언하는 것이다.
타입 단언은 캐스팅(casting)과 같을까?
- 타입 단언 : 타입스크립트에서 컴파일러에게만 지정한 타입으로 취급해달라고 명령하는 것
- 타입 캐스팅 : 하나의 타입을 다른 타입으로 변환하는 것
비슷하지만 약간 다르다.
런타임에서는 아무 영향도 없고, 오직 컴파일 시점의 타입 검사만 달라진다.
다음과 같이 말도 안 되는 단언도 컴파일은 통과된다.
const num = 123 as unknown as string;
➡️ 즉, 개발자가 책임지고 올바르게 사용해야 하는 도구이다.
이 코드에서는 왜 꼭 필요할까?
이 코드에서는 countryData가 CityInfo인지, NestedCityInfo인지에 따라 타입이 다르다.
그래서 아래처럼 in 연산자를 먼저 사용해 타입을 좁히고,
if ("비자" in countryData) {
// 이 안에서는 사실상 CityInfo 타입임
그다음 안전하게 값을 꺼내려면 TypeScript한테
(countryData as CityInfo)[key]
라고 명시적으로 CityInfo로 다룰 수 있게 허락해주는 것이다.
if("비자" in countryData) return (countryData as CityInfo)[key]"비자" in countryData
이건 countryData 객체에 "비자"라는 key가 존재하는지 확인하는 조건이다.
countryData는 CityInfo | NestedCityInfo라는 유니언 타입인데, 이 중에서 "비자"라는 key는 CityInfo에만 있다."비자" in countryData 조건이 참이면 countryData는 CityInfo라고 간주할 수 있다.(countryData as CityInfo)[key]
이건 타입 단언(Type Assertion)이라고 한다.
countryData는 현재 CityInfo | NestedCityInfo 타입이다."비자"가 있으니까 CityInfo로 좁혀졌다고 생각한다.as CityInfo라고 써줘야 에러가 안 난다.전체 해석
if ("비자" in countryData)
return (countryData as CityInfo)[key];
➡️ "비자"라는 key가 존재하면, countryData는 CityInfo 타입이 확실하니까 CityInfo로 단언한 후, key에 해당하는 값을 꺼내서 반환하라.