본문 바로가기

배워서 따라하는 포플/포켓몬 도감 앱

타입스크립트로 변경하기(1)

typescript 설치

npm install -D typescript @types/react @types/react-dom

위 명령어대로 ts를 설치합니다.

vite.config.js를 .ts로 변경합니다.

루트 경로에 tsconfig.json, tsconfig.node.json 파일을 생성하며, src폴더 안에 vite-env.d.ts파일을 생성합니다.

이거는 npm init vite를 하고 typescript를 선택하면 자동적으로 생성한 시스템파일입니다.

새로운 ts프로젝트를 생성 후, 가져와서 붙여넣기해도 좋습니다.

 

type전환 방법

간단한 변수는 직접 작성해도 됩니다.

좀 길고 복잡한 데이터 같은 경우 console.log에서 JSON.stringify(type.data)를 해서 console에 확인합니다.

출력된 내용을 복사해서 app.quicktype.io 에서 형 변환하면 됩니다.

 

index.html 타입 변경

src폴더 안에 main.jsx를 .tsx로 변경합니다.

as HTMLElement를 추가하여 에러를 없앱니다.

경로에 에러 뜨는데 tsconfig.json의 allowJs를 true로 변경하면 경로 에러 안 뜹니다.

루트경로의 index.html에서 main.tsx로 변경합니다.

 

메인 페이지 타입 변경

MainPage폴더안에 index.jsx를 .tsx로 변경합니다. 타입 에러들을 많이 생길겁니다.

export interface PokemonData {
  count: number;
  next: string | null;
  previous: string |null ;
  results: PokemonNameAndUrl[]
}

export interface PokemonNameAndUrl {
  name: string;
  url: string;
}

src폴더 안에 types폴더 생성 후, PokemonData.ts파일의 코드입니다.

앞으로 type은 모두 types폴더 안에서 관리하겠습니다.

import React, { useEffect, useState } from 'react'
import axios from 'axios'
import PokeCard from '../../components/PokeCard'
import AutoComplete from '../../components/AutoComplete'
import { PokemonData, PokemonNameAndUrl } from '../../types/PokemonData'

function MainPage() {

  // 모든 포켓몬 데이터를 가지고 있는 state
  const [allPokemons, setAllPokemons] = useState<PokemonNameAndUrl[]>([])
  // 실제로 리스트로 보여주는 포켓몬 데이터를 가지고 있는 state
  const [displayedPokemons, setDisplayedPokemons] = useState<PokemonNameAndUrl[]>([])
  // 한번에 보여주는 포켓몬 수
  const limitNum = 20
  const url = 'https://pokeapi.co/api/v2/pokemon/?limit=1008&offset=0'

  useEffect(() => {
    fetchPokeData()
  }, [])

  const filterDisplayedPokemonData = (
    allPokemonsData: PokemonNameAndUrl[],
    displayedPokemons: PokemonNameAndUrl[] = []
    ) => {
    const limit = displayedPokemons.length + limitNum
    // 모든 포멧몬 데이터에서 limitNum만큼 더 가져오기
    const array = allPokemonsData.filter((pokemon, index) => index + 1 <= limit)
    return array
  }

  const fetchPokeData = async () => {
    try {
      // 1008 포켓몬 데이터 받아오기
      const response = await axios.get<PokemonData>(url)
      // 모든 포켓몬 데이터 기억하기
      setAllPokemons(response.data.results)
      // 실제로 화면에 보여줄 포켓몬 리스트 기억하기
      setDisplayedPokemons(filterDisplayedPokemonData(response.data.results))
    } catch(error) {
      console.error(error)
    }
  }

  return (
    <article className='pt-6'>
      <header className='flex flex-col gap-2 w-full px-4 z-50'>
        <AutoComplete 
          allPokemons={allPokemons}
          setDisplayedPokemons={setDisplayedPokemons}
        />
      </header>
      <section className='pt-6 flex flex-col justify-content items-center overflow-auto z-0'>
        <div className='flex flex-row flex-wrap gap-[16px] items-center justify-center px-2 max-w-4xl'>
          {displayedPokemons.length > 0 ? 
            (
              displayedPokemons.map(({url, name}: PokemonNameAndUrl) => (
                <PokeCard key={url} url={url} name={name}/>
              ))
            ) :
            (
              <h2 className='font-medium text-lg text-slate-900 mb-1'>
                포켓몬이 없습니다.
              </h2>
            )
          }
        </div>
      </section>

      <div className='text-center'>
        {(allPokemons.length > displayedPokemons.length) && (displayedPokemons.length !== 1) && 
          (
            <button 
              onClick={() => setDisplayedPokemons(filterDisplayedPokemonData(allPokemons, displayedPokemons))}
              className='bg-slate-800 px-6 py-2 my-4 text-base rounded-lg font-bold text-white'>
              더 보기
            </button>
          )
        }
      </div>
    </article>
  )
}

export default MainPage

MainPage폴더안에 index.tsx파일에 필요한 부분에만 타입을 입력했습니다.

 

AutoComplete 컴포넌트 타입 변경

interface AutoCompleteProps {
  allPokemons: PokemonNameAndUrl[],
  setDisplayedPokemons: React.Dispatch<React.SetStateAction<PokemonNameAndUrl[]>>
}

AutoComplete.tsx파일 내부에서 type을 선언합니다.

에러난 부분의 타입을 명시해주면 됩니다.

 

PokeCard 컴포넌트 타입 변경

 

export interface PokemonDetail {
  abilities:                Ability[];
  base_experience:          number;
  forms:                    Species[];
  game_indices:             GameIndex[];
  height:                   number;
  held_items:               any[];
  id:                       number;
  is_default:               boolean;
  location_area_encounters: string;
  moves:                    Move[];
  name:                     string;
  order:                    number;
  past_types:               any[];
  species:                  Species;
  sprites:                  Sprites;
  stats:                    Stat[];
  types:                    Type[];
  weight:                   number;
}

export interface Ability {
  ability:   Species;
  is_hidden: boolean;
  slot:      number;
}

export interface Species {
  name: string;
  url:  string;
}

export interface GameIndex {
  game_index: number;
  version:    Species;
}

export interface Move {
  move:                  Species;
  version_group_details: VersionGroupDetail[];
}

export interface VersionGroupDetail {
  level_learned_at:  number;
  move_learn_method: Species;
  version_group:     Species;
}

export interface GenerationV {
  "black-white": Sprites;
}

export interface GenerationIv {
  "diamond-pearl":        Sprites;
  "heartgold-soulsilver": Sprites;
  platinum:               Sprites;
}

export interface Versions {
  "generation-i":    GenerationI;
  "generation-ii":   GenerationIi;
  "generation-iii":  GenerationIii;
  "generation-iv":   GenerationIv;
  "generation-v":    GenerationV;
  "generation-vi":   { [key: string]: Home };
  "generation-vii":  GenerationVii;
  "generation-viii": GenerationViii;
}

export interface Sprites {
  back_default:       string;
  back_female:        null;
  back_shiny:         string;
  back_shiny_female:  null;
  front_default:      string;
  front_female:       null;
  front_shiny:        string;
  front_shiny_female: null;
  other?:             Other;
  versions?:          Versions;
  animated?:          Sprites;
}

export interface GenerationI {
  "red-blue": RedBlue;
  yellow:     RedBlue;
}

export interface RedBlue {
  back_default:      string;
  back_gray:         string;
  back_transparent:  string;
  front_default:     string;
  front_gray:        string;
  front_transparent: string;
}

export interface GenerationIi {
  crystal: Crystal;
  gold:    Gold;
  silver:  Gold;
}

export interface Crystal {
  back_default:            string;
  back_shiny:              string;
  back_shiny_transparent:  string;
  back_transparent:        string;
  front_default:           string;
  front_shiny:             string;
  front_shiny_transparent: string;
  front_transparent:       string;
}

export interface Gold {
  back_default:       string;
  back_shiny:         string;
  front_default:      string;
  front_shiny:        string;
  front_transparent?: string;
}

export interface GenerationIii {
  emerald:             OfficialArtwork;
  "firered-leafgreen": Gold;
  "ruby-sapphire":     Gold;
}

export interface OfficialArtwork {
  front_default: string;
  front_shiny:   string;
}

export interface Home {
  front_default:      string;
  front_female:       null;
  front_shiny:        string;
  front_shiny_female: null;
}

export interface GenerationVii {
  icons:                  DreamWorld;
  "ultra-sun-ultra-moon": Home;
}

export interface DreamWorld {
  front_default: string;
  front_female:  null;
}

export interface GenerationViii {
  icons: DreamWorld;
}

export interface Other {
  dream_world:        DreamWorld;
  home:               Home;
  "official-artwork": OfficialArtwork;
}

export interface Stat {
  base_stat: number;
  effort:    number;
  stat:      Species;
}

export interface Type {
  slot: number;
  type: Species;
}

types폴더 안에 PokemonDetail.ts 파일을 위와 같이 작성합니다.

interface PokeData {
  id: number;
  type: string;
  name: string;
}

PokeCard.tsx파일 내부에서 type을 선언합니다.

에러난 부분의 타입을 명시해주면 됩니다.

PokeData와 PokemonDetail는 import해야 됩니다.

 

LazyImage 컴포넌트 타입 변경

interface LazyImageProps {
  url: string;
  alt: string;
}

LazyImage.tsx파일 내부에서 type을 선언합니다.

에러난 부분의 타입을 명시해주면 됩니다.

 

상세페이지 타입 변경

export interface FormattedPokemonData {
  id:              number;
  name:            string;
  weight:          number;
  height:          number;
  previous:        string | undefined;
  next:            string | undefined;
  abilities:       string[];
  stats:           Stat[];
  DamageRelations: DamageRelation[];
  types:           string[];
  sprites:         string[];
  description:     string;
}

export interface DamageRelation {
  double_damage_from: DoubleDamageFrom[];
  double_damage_to:   DoubleDamageFrom[];
  half_damage_from:   DoubleDamageFrom[];
  half_damage_to:     DoubleDamageFrom[];
  no_damage_from:     any[];
  no_damage_to:       any[];
}

export interface DoubleDamageFrom {
  name: string;
  url:  string;
}

export interface Stat {
  name:     string;
  baseStat: number;
}

types폴더 안에 FormattedPokemonData.ts 파일의 코드를 위와 같이 작성합니다.

export interface PokemonDescription {
  base_happiness:         number;
  capture_rate:           number;
  color:                  Color;
  egg_groups:             Color[];
  evolution_chain:        EvolutionChain;
  evolves_from_species:   Color;
  flavor_text_entries:    FlavorTextEntry[];
  form_descriptions:      any[];
  forms_switchable:       boolean;
  gender_rate:            number;
  genera:                 Genus[];
  generation:             Color;
  growth_rate:            Color;
  habitat:                Color;
  has_gender_differences: boolean;
  hatch_counter:          number;
  id:                     number;
  is_baby:                boolean;
  is_legendary:           boolean;
  is_mythical:            boolean;
  name:                   string;
  names:                  Name[];
  order:                  number;
  pal_park_encounters:    PalParkEncounter[];
  pokedex_numbers:        PokedexNumber[];
  shape:                  Color;
  varieties:              Variety[];
}

export interface Color {
  name: string;
  url:  string;
}

export interface EvolutionChain {
  url: string;
}

export interface FlavorTextEntry {
  flavor_text: string;
  language:    Color;
  version:     Color;
}

export interface Genus {
  genus:    string;
  language: Color;
}

export interface Name {
  language: Color;
  name:     string;
}

export interface PalParkEncounter {
  area:       Color;
  base_score: number;
  rate:       number;
}

export interface PokedexNumber {
  entry_number: number;
  pokedex:      Color;
}

export interface Variety {
  is_default: boolean;
  pokemon:    Color;
}

types폴더 안에 PokemonDescription.ts 파일의 코드를 위와 같이 작성합니다.

export interface DamageRelationOfPokemonType {
  damage_relations:      DamageRelations;
  game_indices:          GameIndex[];
  generation:            Generation;
  id:                    number;
  move_damage_class:     Generation;
  moves:                 Generation[];
  name:                  string;
  names:                 Name[];
  past_damage_relations: PastDamageRelation[];
  pokemon:               Pokemon[];
}

export interface DamageRelations {
  double_damage_from: Generation[];
  double_damage_to:   Generation[];
  half_damage_from:   Generation[];
  half_damage_to:     Generation[];
  no_damage_from:     Generation[];
  no_damage_to:       Generation[];
}

export interface Generation {
  name: string;
  url:  string;
}

export interface GameIndex {
  game_index: number;
  generation: Generation;
}

export interface Name {
  language: Generation;
  name:     string;
}

export interface PastDamageRelation {
  damage_relations: DamageRelations;
  generation:       Generation;
}

export interface Pokemon {
  pokemon: Generation;
  slot:    number;
}

types폴더 안에 DamageRelationOfPokemonTypes.ts 파일의 코드를 위와 같이 작성합니다.

import axios from 'axios';
import React, { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Loading } from '../../assets/Loading';
import { LessThan } from '../../assets/LessThan';
import { GreaterThan } from '../../assets/GreaterThan';
import { ArrowLeft } from '../../assets/ArrowLeft';
import { Balance } from '../../assets/Balance';
import { Vector } from '../../assets/Vector';
import Type from '../../components/Type';
import BaseStat from '../../components/BaseStat';
import DamageRelations from '../../components/DamageRelations';
import DamageModal from '../../components/DamageModal';
import { FormattedPokemonData } from '../../types/FormattedPokemonData';
import { Ability, PokemonDetail, Sprites, Stat } from '../../types/PokemonDetail';
import { DamageRelationOfPokemonType } from '../../types/DamageRelationOfPokemonTypes';
import { FlavorTextEntry, PokemonDescription } from '../../types/PokemonDescription';
import { PokemonData } from '../../types/PokemonData';

interface NextAndPreviousPokemon {
  next: string | undefined;
  previous: string | undefined;
}

const DetailPage = () => {

  const [pokemon, setPokemon] = useState<FormattedPokemonData>()
  const [isLoading, setIsLoading] = useState<boolean>(false)

  const [isModalOpen, setIsModalOpen] = useState<boolean>(false)

  const params = useParams() as {id: string}
  const pokemonId = params.id
  const baseUrl = 'https://pokeapi.co/api/v2/pokemon/'

  useEffect(() => {
    setIsLoading(true)
    fetchPokemonData(pokemonId)
  }, [pokemonId])

  async function fetchPokemonData(pokemonId: string) {
    const url = `${baseUrl}${pokemonId}`
    try{
      const {data: pokemonData} = await axios.get<PokemonDetail>(url)

      if(pokemonData) {
        const { name, id, types, weight, height, stats, abilities, sprites} = pokemonData
        const nextAndPreviousPokemon: NextAndPreviousPokemon = await getNextAndPreviousPokemon(id)

        const DamageRelations = await Promise.all(
          types.map(async (i) => {
            const type = await axios.get<DamageRelationOfPokemonType>(i.type.url)
            return type.data.damage_relations
          })
        )

        const formattedPokemonData: FormattedPokemonData = {
          id,
          name,
          weight: weight / 10,
          height: height / 10,
          previous: nextAndPreviousPokemon.previous,
          next: nextAndPreviousPokemon.next,
          abilities: formatPokemonAbilities(abilities),
          stats: formatPokemonStats(stats),
          DamageRelations,
          types: types.map(type => type.type.name),
          sprites: formatPokemonSprites(sprites),
          description: await getPokemonDescription(id)
        }

        setPokemon(formattedPokemonData)
        setIsLoading(false)
      }
    } catch(error) {
      console.error(error)
      setIsLoading(false)
    }
  }

  const filterAndFormatDescription = (flavorText: FlavorTextEntry[]): string[] => {
    const koreanDescriptions = flavorText
      ?.filter((text) => text.language.name === "ko")
      .map((text) => text.flavor_text.replace(/\r|\n|\f/g, ' '))

    return koreanDescriptions
  }

  const getPokemonDescription = async (id: number): Promise<string> => {
    const url =`https://pokeapi.co/api/v2/pokemon-species/${id}/`

    const {data: pokemonSpecies} = await axios.get<PokemonDescription>(url)

    const descriptions = filterAndFormatDescription(pokemonSpecies.flavor_text_entries)

    return descriptions[Math.floor(Math.random() * descriptions.length)]
  }

  const formatPokemonSprites = (sprites: Sprites) => {
    const newSprites = {...sprites};

    (Object.keys(newSprites) as (keyof typeof newSprites)[]).forEach(key => {
      if(typeof newSprites[key] !== 'string') {
        delete newSprites[key]
      }
    })    
    
    return Object.values(newSprites) as string[]
  }

  const formatPokemonStats = ([
    statHP,
    statATK,
    statDEP,
    statSATK,
    statSDEP,
    statSPD
  ]: Stat[]) => [
    {name: 'Hit Points', baseStat: statHP.base_stat},
    {name: 'Attack', baseStat: statATK.base_stat},
    {name: 'Defense', baseStat: statDEP.base_stat},
    {name: 'Special Attack', baseStat: statSATK.base_stat},
    {name: 'Special Defense', baseStat: statSDEP.base_stat},
    {name: 'Speed', baseStat: statSPD.base_stat}
  ]

  const formatPokemonAbilities = (abilities: Ability[]) => {
    return abilities.filter((ability, index) => index <= 1).map((obj) => obj.ability.name.replaceAll('-', ''))
  }

  async function getNextAndPreviousPokemon(id: number) {
    const urlPokemon = `${baseUrl}?limit=1&offset=${id - 1}`

    const { data: pokemonData } = await axios.get(urlPokemon)

    const nextResponse = pokemonData.next && (await axios.get<PokemonData>(pokemonData.next))
    const previousResponse = pokemonData.previous && (await axios.get<PokemonData>(pokemonData.previous))

    return {
      next: nextResponse?.data?.results?.[0].name,
      previous: previousResponse?.data?.results?.[0].name
    }
  }

  if(isLoading) {
    return (
      <div className='absolute h-auto w-auto top-1/3 -translate-x-1/2 left-1/2 z-50'>
        <Loading className='w-12 h-12 z-50 animate-spin text-slate-900' />
      </div>
    )
  }

  if(!isLoading && !pokemon) {
    return (
      <div>...NOT FOUND</div>
    )
  }

  const img = `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${pokemon?.id}.png`
  const bg = `bg-${pokemon?.types?.[0]}`
  const text = `text-${pokemon?.types?.[0]}`
  
  return (
    <article className='flex items-center gap-1 flex-col w-full'>
      <div className={`${bg} w-auto h-full flex flex-col z-0 items-center justify-end  relative overflow-hidden`} >
        {pokemon?.previous && (
          <Link 
            className='absolute top-[40%] -translate-y-1/2 z-50 left-1'
            to={`/pokemon/${pokemon.previous}`}
          >
            <LessThan className='w-5 h-8 p-1'/>
          </Link>
        )}

        {pokemon?.next && (
          <Link 
            className='absolute top-[40%] -translate-y-1/2 z-50 right-1'
            to={`/pokemon/${pokemon.next}`}
          >
            <GreaterThan className='w-5 h-8 p-1'/>
          </Link>
        )}

        <section className='w-full flex flex-col z-20 items-center justify-end relative h-full'>
          <div className='absolute z-30 top-6 flex items-center w-full justify-between px-2'>
            <div className='flex items-center gap-1'>
              <Link to="/">
                <ArrowLeft className='w-6 h-8 text-zinc-200' />
              </Link>
              <h1 className='text-zinc-200 font-bold text-xl capitalize'>
                {pokemon?.name}
              </h1>
            </div>
            <div className='text-zinc-200 font-bold text-md'>
              #{pokemon?.id.toString().padStart(3, '00')}
            </div>
          </div>

          <div className='relative h-auto max-w-[15.5rem] z-20 mt-6 -mb-16'>
            <img 
              onClick={() => setIsModalOpen(true)}
              src={img} width='100%' height='auto' loading='lazy' alt={pokemon.name} className='object-contain h-full' />
          </div>
        </section>

        <section className='w-full min-h-[65%] h-full bg-gray-800 z-10 pt-14 flex flex-col items-center gap-3 px-5 pb-4'>
          <div className='flex items-center justify-center gap-4'>
            {pokemon?.types.map((type) => (
              <Type key={type} type={type} />
            ))}
          </div>

          <h2 className={`text-base font-semibold ${text}`}>
            정보
          </h2>

          <div className='flex w-full items-center justify-center max-w-[400px] text-center'>
            <div className='w-full'>
              <h4 className='text-[0.5rem] text-zinc-100'>Weight</h4>
              <div className='text-sm flex mt-1 gap-2 justify-center text-zinc-200'>
                <Balance />
                {pokemon?.weight}kg
              </div>
            </div>

            <div className='w-full'>
              <h4 className='text-[0.5rem] text-zinc-100'>Height</h4>
              <div className='text-sm flex mt-1 gap-2 justify-center text-zinc-200'>
                <Vector />
                {pokemon.height}m
              </div>
            </div>

            <div className='w-full'>
              <h4 className='text-[0.5rem] text-zinc-100'>Abilities</h4>
              {pokemon.abilities.map((ability) => (
                <div key={ability} className='text-[0.5rem] text-zinc-100 capitalize'>{ability}</div>
              ))}
            </div>
          </div>

          <h2 className={`text-base font-semibold ${text}`}>
            기본 능력치
          </h2>
          <div className='w-full'>
            <table>
              <tbody>
                {pokemon.stats.map((stat) => (
                  <BaseStat 
                    key={stat.name}
                    valueStat={stat.baseStat}
                    nameState={stat.name}
                    type={pokemon.types[0]}
                  />
                ))}
              </tbody>
            </table>
          </div>

          <h2 className={`text-base font-semibold ${text}`}>
            설명
          </h2>
          <p className='text-md leading-4 font-sans text-zinc-200 max-w-[30rem] text-center'>
            {pokemon.description}
          </p>

          <div className="flex my-8 flex-wrap justify-center">
            {pokemon.sprites.map((url,index) => (
              <img key={index} src={url} alt='sprite' />
            ))}
          </div>

        </section>
      </div>

      {isModalOpen && <DamageModal setIsModalOpen={setIsModalOpen} damages={pokemon.DamageRelations} />}

    </article>
  )
}

export default DetailPage

수정된 DetailPage의 index.tsx입니다. (일부 미완성)

 

Assets 컴포넌트들 타입 변경

export interface ClassNameProps {
    className: string;
}

types폴더 안에 ClassNameProps.ts 파일의 코드를 위와 같이 작성합니다.

Assets폴더 안에 컴포넌트들을 다 타입을 명시합니다.