cva with 타입스크립트?
포켓몬 도감 비스무리한 개인과제 프로젝트를 하면서,
cva 라이브러리를 사용 중에 타입지정에 매우 애를 먹었습니다.
원래 이렇게 쓰는건가 싶습니다...? 더 공부해봐야겠습니다 ㅠㅠ
아래의 예는 포켓몬의 타입 18개에만 해당하는 것이라 문제가 덜하지만
속성인지 뭔지는 게임의 세대가 변화될때마다 추가되어서 뭐 몇개인지도 모르겠어요.
이것을 다 리터럴 타입으로 지정하자니 너무 힘들고 해서 mapped type 을 사용하긴 했는데
되는건지 뭔지 모르겠습니다?
관련 내용을 정리합니다.
cva chip 컴포넌트 만들기
포켓몬의 18가지 타입에 맞는 Chip
컴포넌트를 만들기 위해 기존의 chipVariants
객체에 포켓몬 타입별 색상을 추가합니다.
각각의 타입에 적절한 배경색, 테두리색, 텍스트 색을 지정하여 포켓몬 타입에 맞는 시각적 효과를 줄 수 있습니다.
포알못이라 대강 했습니다. 하드코딩...
import { cva, VariantProps } from "class-variance-authority";
const chipVariants = cva(
[
"text-sm border",
"rounded-full",
"px-2.5",
"py-0.5",
"hover:opacity-70",
"transition-opacity",
],
{
variants: {
intent: {
primary: "bg-blue-500 border-blue-500 text-white",
secondary: "bg-gray-500 border-gray-500 text-white",
danger: "bg-red-500 border-red-500 text-white",
warning: "bg-yellow-500 border-yellow-500 text-white",
info: "bg-violet-500 border-violet-500 text-white",
success: "bg-green-500 border-green-500 text-white",
default: "bg-white border-black text-black",
normal: "bg-gray-400 border-gray-400 text-white",
fire: "bg-red-600 border-red-600 text-white",
water: "bg-blue-600 border-blue-600 text-white",
electric: "bg-yellow-400 border-yellow-400 text-black",
grass: "bg-green-500 border-green-500 text-white",
ice: "bg-blue-300 border-blue-300 text-black",
fighting: "bg-orange-700 border-orange-700 text-white",
poison: "bg-purple-500 border-purple-500 text-white",
ground: "bg-yellow-700 border-yellow-700 text-white",
flying: "bg-indigo-400 border-indigo-400 text-white",
psychic: "bg-pink-500 border-pink-500 text-white",
bug: "bg-green-700 border-green-700 text-white",
rock: "bg-yellow-800 border-yellow-800 text-white",
ghost: "bg-purple-700 border-purple-700 text-white",
dragon: "bg-indigo-700 border-indigo-700 text-white",
dark: "bg-gray-800 border-gray-800 text-white",
steel: "bg-gray-500 border-gray-500 text-white",
fairy: "bg-pink-300 border-pink-300 text-black",
},
},
defaultVariants: {
intent: "default",
},
}
);
type ChipVariantsType = VariantProps<typeof chipVariants>;
type ChipProps = {
label: string;
intent?: string;
} & ChipVariantsType;
function Chip({ intent = "default", label }: ChipProps) {
return <div className={chipVariants({ intent })}>{label}</div>;
}
export default Chip;
위 코드에서 포켓몬의 18가지 타입에 맞는 색상을 chipVariants
객체의 intent
속성에 추가했습니다.
Chip
컴포넌트는 intent
속성을 받아 해당 타입의 색상으로 렌더링합니다.
여기서 아래쪽 타입 부분만 기억하면 될 것 같습니다.
type ChipVariantsType = VariantProps<typeof chipVariants>;
이렇게 VariantProps 를 이용해서 지정하면 된다고 하네요.
포켓몬 특성이 너무 많잖아? 어떡하지
타입은 18개지만 특성은 진짜 너무 많습니다. 리터럴 타입으로 전부 대응하자니 너무 힘들고
아래처럼 처리하려 했지만 chipVariants.variants.intent;
이 부분에서 variants 가 없고 어쩌고 에러 납니다.
function AbilitiesChip({ intent = "default", label }: ChipProps) {
// 아래 구문은 사실상 사용 불가
const isValidIntent = (intent: string): intent is keyof typeof
chipVariants.variants.intent => intent in chipVariants.variants.intent;
return <div className={chipVariants({
intent: isValidIntent(intent) ? intent : "default" })}>{label}</div>;
}
결국 이래저래 해메이다가 하드코딩으로 해결했습니다 ^0^~~ ㅠㅠ
export type PokemonAbility =
| "air-lock"
| "arena-trap"
// 중략 .... 너무많음 뭐 이렇게 많아!
| "unburden";
const chipVariants = cva(
[
"text-sm border",
"rounded-full",
"px-2.5",
"py-0.5",
"hover:opacity-70",
"transition-opacity",
],
{
variants: {
intent: {
"air-lock": "bg-blue-100 border-blue-100 text-blue-900",
// 중략: 다른 variants.intent 속성들 역시 너무 많음 위 타입과 일치해야함
"unaware": "bg-blue-900 border-blue-900 text-blue-900",
"unburden": "bg-blue-900 border-blue-900 text-blue-900",
},
},
defaultVariants: {
intent: "default",
},
}
);
type ChipVariantsType = VariantProps<typeof chipVariants>;
type ChipProps = {
label: string;
intent?: string;
} & ChipVariantsType;
function isValidIntent(intent: string): intent is PokemonAbility {
const validIntents: PokemonAbility[] = [
"air-lock", "arena-trap", "battle-armor", "blaze", "cacophony",
"chlorophyll", "clear-body", "cloud-nine", "color-change",
// 결국 이런식으로 해결 야호~~~~~~~~~~~~
"solid-rock", "stall", "steadfast", "storm-drain", "super-luck",
"tangled-feet", "technician", "tinted-lens", "unaware", "unburden"
];
return validIntents.includes(intent as PokemonAbility);
}
function AbilitiesChip({ intent = "default", label }: ChipProps) {
return <div className={chipVariants({ intent: isValidIntent(intent) ? intent : "default" })}>{label}</div>;
}
export default AbilitiesChip;
*사실 이럴바에는 pokemonAbility 를 배열로 만들어놓고 아래처럼 하는게 현명할 것입니다!!
const assertions
export const abilities = [
"air-lock", "arena-trap", "battle-armor",
// 기타 등등....
] as const;
export type PokemonAbilityTypes = typeof abilites[number];
아래는 제가 지피티 쌤과 나눈 대화를 정리합니다.
맵드타입을 제가 잘 의도하고 쓴 것 맞나요?
export type PokemonMapped<T> = {
[Property in keyof T]: T[Property];
};
맵드 타입의 기본 개념은 TypeScript에서 주어진 타입의 모든 속성을 순회하면서 새로운 타입을 생성하는 것입니다.
PokemonMapped<T>
는 주어진 타입 T
의 속성을 그대로 유지하는 새로운 타입을 정의하는 것으로 보입니다. 그러나 이 경우 맵드 타입의 사용이 필요하지 않습니다. 왜냐하면 주어진 타입 T
를 그대로 반환하는 것과 동일하기 때문입니다.
맵드 타입은 속성의 타입을 변경하거나 속성 이름을 변경하는 등의 작업에 유용하게 사용됩니다. 따라서 현재 예제는 의미가 없고, 단순히 T
타입을 반환하는 것과 동일합니다.
하지만 만약 속성의 타입을 변경하거나 추가적인 변환을 하고 싶다면 맵드 타입을 사용하는 것이 맞습니다. 예를 들어, T
타입의 모든 속성을 선택적으로 만드는 맵드 타입은 다음과 같습니다:
export type PokemonMapped<T> = {
[Property in keyof T]?: T[Property];
};
이 예제에서는 PokemonMapped<T>
타입이 T
타입의 모든 속성을 선택적으로 만듭니다. 이는 원래 타입의 모든 속성을 optional
로 만들고자 할 때 유용합니다.
또는 모든 속성 타입을 특정 타입으로 변환하고자 할 때도 사용할 수 있습니다:
export type PokemonMapped<T> = {
[Property in keyof T]: string;
};
이 예제에서는 PokemonMapped<T>
타입이 T
타입의 모든 속성을 string
타입으로 변환합니다.
기본적으로 PokemonMapped<T>
를 사용하여 단순히 주어진 타입 T
의 모든 속성을 순회하면서 원래 타입을 그대로 유지하는 경우는 불필요합니다. 대신, 구체적인 변환이 필요할 때만 맵드 타입을 사용하는 것이 좋습니다.
export type PokemonMapped<T> = T
이거랑 똑같은가요?
아니요, 두 타입은 동일하지 않습니다. PokemonMapped<T>
타입은 T
타입의 모든 속성을 선택적(optional)으로 만듭니다.
반면 export type PokemonMapped<T> = T;
는 주어진 타입 T
를 그대로 사용하는 것입니다.
예를 들어, 다음과 같은 타입이 있다고 가정해 봅시다:
type Pokemon = {
id: number;
name: string;
type: string;
};
PokemonMapped<Pokemon>
와 Pokemon
타입을 비교해 보겠습니다.
export type PokemonMapped<T> = T;
의 경우:
type PokemonMapped<T> = T;
type MappedPokemon = PokemonMapped<Pokemon>;
// MappedPokemon 타입은 Pokemon 타입과 동일합니다
// MappedPokemon 타입은 { id: number; name: string; type: string; }
export type PokemonMapped<T> = { [Property in keyof T]?: T[Property]; };
의 경우:
type PokemonMapped<T> = {
[Property in keyof T]?: T[Property];
};
type MappedPokemon = PokemonMapped<Pokemon>;
// MappedPokemon 타입은 Pokemon 타입의 모든 속성을 선택적으로 만든 타입입니다
// MappedPokemon 타입은 { id?: number; name?: string; type?: string; }
따라서 두 가지 타입은 다음과 같은 차이가 있습니다:
PokemonMapped<T> = T;
는 주어진 타입T
를 그대로 반환합니다.PokemonMapped<T> = { [Property in keyof T]?: T[Property]; };
는 주어진 타입T
의 모든 속성을 선택적으로 만듭니다.
예제 코드
다음은 각각의 타입을 사용하는 예제 코드입니다:
type Pokemon = {
id: number;
name: string;
type: string;
};
// T 타입을 그대로 사용하는 경우
type PokemonMapped1<T> = T;
type MappedPokemon1 = PokemonMapped1<Pokemon>;
const pokemon1: MappedPokemon1 = {
id: 1,
name: "Bulbasaur",
type: "Grass/Poison"
}; // 정상
const pokemon1Optional: MappedPokemon1 = {
name: "Bulbasaur"
}; // 오류: 'id'와 'type' 속성이 필요합니다
// T 타입의 모든 속성을 선택적으로 만드는 경우
type PokemonMapped2<T> = {
[Property in keyof T]?: T[Property];
};
type MappedPokemon2 = PokemonMapped2<Pokemon>;
const pokemon2: MappedPokemon2 = {
id: 1,
name: "Bulbasaur",
type: "Grass/Poison"
}; // 정상
const pokemon2Optional: MappedPokemon2 = {
name: "Bulbasaur"
}; // 정상: 'id'와 'type' 속성은 선택적입니다
따라서 두 타입 정의는 서로 다르며, PokemonMapped<T>
가 어떤 용도로 사용될 것인지에 따라 적절히 선택해야 합니다.
그렇다면 const assertions 을 쓰는게 맞을까요?
네, 정확합니다. as const
를 사용하여 배열을 리터럴 타입으로 변환한 후, 해당 배열의 요소 타입을 유니언 타입으로 추출하는 방법은 매우 유용합니다. 이렇게 하면 타입 안전성을 유지하면서도 코드를 간결하게 작성할 수 있습니다.
아래는 예시입니다:
const abilities = [
"air-lock", "arena-trap", "battle-armor",
// 기타 등등...
] as const;
export type PokemonAbilityTypes = typeof abilities[number];
이 코드는 다음과 같은 타입을 생성합니다:
abilities
는 읽기 전용 배열로 정의되며, 배열의 각 요소는 리터럴 타입으로 간주됩니다.PokemonAbilityTypes
는 배열abilities
의 요소 타입을 유니언 타입으로 정의합니다.
이제 PokemonAbilityTypes
타입을 사용하여 AbilitiesChip
컴포넌트의 intent
prop을 타입 안전하게 만들 수 있습니다:
import { cva, VariantProps } from "class-variance-authority";
// abilities 배열과 타입 정의
const abilities = [
"air-lock", "arena-trap", "battle-armor",
// 기타 등등...
"unaware", "unburden"
] as const;
export type PokemonAbilityTypes = typeof abilities[number];
// chipVariants 정의
const chipVariants = cva(
[
"text-sm border",
"rounded-full",
"px-2.5",
"py-0.5",
"hover:opacity-70",
"transition-opacity",
],
{
variants: {
intent: {
"air-lock": "bg-blue-100 border-blue-100 text-blue-900",
// 기타 등등...
"unaware": "bg-blue-900 border-blue-900 text-blue-900",
"unburden": "bg-blue-900 border-blue-900 text-blue-900",
},
},
defaultVariants: {
intent: "default",
},
}
);
type ChipVariantsType = VariantProps<typeof chipVariants>;
type ChipProps = {
label: string;
intent?: PokemonAbilityTypes; // 타입 안전성을 보장하기 위해 PokemonAbilityTypes 사용
} & ChipVariantsType;
// isValidIntent 함수 정의
function isValidIntent(intent: string): intent is PokemonAbilityTypes {
return abilities.includes(intent as PokemonAbilityTypes);
}
// AbilitiesChip 컴포넌트 정의
function AbilitiesChip({ intent = "default", label }: ChipProps) {
return <div className={chipVariants({ intent: isValidIntent(intent) ? intent : "default" })}>{label}</div>;
}
export default AbilitiesChip;
이 코드에서는:
abilities
배열을as const
로 선언하여 리터럴 타입으로 변환합니다.PokemonAbilityTypes
타입을typeof abilities[number]
로 선언하여 배열 요소의 유니언 타입을 추출합니다.intent
prop의 타입을PokemonAbilityTypes
로 지정하여 타입 안전성을 보장합니다.isValidIntent
함수는intent
가abilities
배열에 포함되어 있는지 확인합니다.
이렇게 하면 AbilitiesChip
컴포넌트의 intent
prop이 PokemonAbilityTypes
타입에 속하는 값만 가질 수 있게 되어, 타입 안전성을 높일 수 있습니다.
'library' 카테고리의 다른 글
[240813 TIL] twMerge (0) | 2024.08.13 |
---|---|
[240713 TIL] 무한스크롤 with Tanstack Query (0) | 2024.07.12 |
[240701 TIL] (공식문서번역)Advanced Server Rendering (0) | 2024.06.30 |
[240630 TIL] (공식문서번역)Server Rendering & Hydration (0) | 2024.06.30 |
[240629 TIL] (공식문서 번역)prefetchQuery (0) | 2024.06.29 |