next.js에 커스텀 타입 사용
2020/09/27 23:29
_app과 페이지 컴포넌트에 타입 추가하기
#nextjs#typescript

next.js는 기본적으로 typescript를 지원하고 있기 때문에 typescript를 설치하는 것만으로 쉽게 사용이 가능하다. next.js의 정해진 룰 안에서만 사용한다면 아무런 문제가 되지 않지만 만약 그렇지 않다면 type 또한 같이 수정이 필요하다.

해보면 어렵지 않지만 막상 하려면 살짝 귀찮음이 생긴다. with-typescript 예제로 한 단계씩 수정을 하면서 커스텀 타입을 정의하는 법을 정리해 본다.

일단 예제 파일을 받아서 열어보자.

npx create-next-app --example with-typescript with-typescript-app
# or
yarn create next-app --example with-typescript with-typescript-app

작업을 할 대상은 pages/index.tsx 파일이다. 여기에 next에서 제공하는 pre-rendering을 적용한다고 가정해보자. 여기선 getInitialProps를 사용한다.

import Link from 'next/link';
import Layout from '../components/Layout';

const IndexPage = () => (
  <Layout title="Home | Next.js + TypeScript Example">
    <h1>Hello Next.js 👋</h1>
    <p>
      <Link href="/about">
        <a>About</a>
      </Link>
    </p>
  </Layout>
);

IndexPage.getInitialProps = () => {};

export default IndexPage;

아직은 아무런 type이 적용되어 있지 않다. 그래서 IndexPage.을 입력해도 intellisense에 getInitialProps를 볼 수는 없다. 하지만 nextjs에서는 페이지를 담당하는 컴포넌트를 위한 NextPage 타입을 제공하고 있고 적용하고 다시 타이핑을 해보면 getInitialProps가 나오는 것을 볼 수 있다. 하지만 위의 코드는 에러가 나고 있다. 이유는 getInitialProps의 타입이 객체를 반환하도록 되어 있기 때문이다. 그래서 일단 빈 객체를 반환하도록 수정한다.

const IndexPage: NextPage = () => (
  <Layout title="Home | Next.js + TypeScript Example">
    <h1>Hello Next.js 👋</h1>
    <p>
      <Link href="/about">
        <a>About</a>
      </Link>
    </p>
  </Layout>
);

IndexPage.getInitialProps = () => ({});

export default IndexPage;

NextPage와 관련된 타입을 한 번 확인해보자.

export type NextPage<P = {}, IP = P> = NextComponentType<
  NextPageContext,
  IP,
  P
>;

export declare type NextComponentType<
  C extends BaseContext = NextPageContext,
  IP = {},
  P = {}
> = ComponentType<P> & {
  /**
   * Used for initial page load data population. Data returned from `getInitialProps` is serialized when server rendered.
   * Make sure to return plain `Object` without using `Date`, `Map`, `Set`.
   * @param ctx Context of `page`
   */

  getInitialProps?(context: C): IP | Promise<IP>;
};

NextPage는 다시 NextComponentType를 사용하고 NextComponentTypeComponentTypegetInitialProps를 포함하고 있는 것을 알 수 있다. 조금 더 구체적으로 보면 컴포넌트의 props의 타입인 P, getInitialProps의 매개변수 타입 C(NextPageContext)와 반환 타입인 IP를 확인할 수 있다. 그리고 NextPage에서 props의 타입 P만 입력하면 이를 IP로도 함께 사용하는 것을 볼 수 있다. 즉 getInitialProps의 반환 타입을 컴포넌트 props의 타입과 동일하게 한다. (nextjs에서 기본적으로 getInitialProps의 반환 값을 각 페이지 컴포넌트에 props로 넣어준다.)

이제 위 예제에 있는 title(Home | Next.js + TypeScript Example)과 링크 텍스트인 AboutgetInitialProps를 통해 받아온다고 가정하고 코드를 수정해보자.

interface Props {
  title: string;
  text: string;
}

const IndexPage: NextPage<Props> = ({ title, text }) => (
  <Layout title={title}>
    <h1>Hello Next.js 👋</h1>
    <p>
      <Link href="/about">
        <a>{text}</a>
      </Link>
    </p>
  </Layout>
);

IndexPage.getInitialProps = () => ({
  title: 'Home | Next.js + TypeScript Example',
  text: 'About',
});

export default IndexPage;

Props 인터페이스를 정의하고 이를 사용해서 NextPage<Props>로 지정해준다. 그러면 getInitialProps의 반환 타입과 IndexPage의 매개변수에 타입이 함께 잡히는 것을 볼 수 있다. 타입을 사용하지 않았을 때 보다 실수할 가능성이 없고 명확해졌다.

다음으로는 title의 일부를 App을 통해서 받아와서 사용해야 하는 경우라고 가정해보자. redux를 함께 사용한다면 getInitialProps에서 store에 접근해야 하는 경우가 잦은데, 이럴 때 여러 가지 방법이 있겠지만 next-redux-wrapper를 사용하면 편하다. 실제로 next-redux-wrappergetInitialProps의 파라미터로 store를 넘겨준다. 물론 타입 정의도 지원하는데 declare module을 사용해서 기존 NextPageContextstore를 추가해주고 있다.

declare module 'next/dist/next-server/lib/utils' {
  interface NextPageContext<S = any, A extends Action = AnyAction> {
    /**
     * Provided by next-redux-wrapper: The redux store
     */
    store: Store<S, A>;
  }
}

지금은 title을 받는 경우를 대비해 타입을 정의해보자. 우선은 getInitialProps의 매개변수 타입을 확장해야 한다. 기존의 NextPageContext을 확장해서 title을 추가한다. (타입을 정의하는데 interfacetype을 사용할 수 있다. 지금은 type을 사용한다.)

export type MyPageContext = NextPageContext & {
  title: string;
};

다음으로는 해당 타입을 사용하는 컴포넌트의 타입을 정의한다. 여기서 NextPage를 확장할 수는 없다. 왜냐하면 NextPageNextPageContext를 사용하고 있고 변경할 수 없기 때문이다. 정의한 MyPageContext를 사용하려면 NextComponentType을 사용해야 한다. 기본적으로 동일한 형태에 MyPageContext만 변경했지만, getInitialProps 외에 추가적인 커스텀 속성을 정의해서 사용한다면 추가한다.

export type MyPage<P = {}, IP = P> = NextComponentType<MyPageContext, IP, P>;

이렇게 정의한 MyPage를 컴포넌트에 적용해보면, getInitialProps의 매개변수에도 title이 잡히는 걸 볼 수 있다.

interface Props {
  title: string;
  text: string;
}

const IndexPage: MyPage<Props> = ({ title, text }) => (
  <Layout title={title}>
    <h1>Hello Next.js 👋</h1>
    <p>
      <Link href="/about">
        <a>{text}</a>
      </Link>
    </p>
  </Layout>
);

IndexPage.getInitialProps = ({ title }) => ({
  title,
  text: 'About',
});

export default IndexPage;

하지만 실제로 동작하기 위해서는 getInitialPropstitle을 넣어주는 과정이 필요하다.

이를 위해서는 Custom App을 정의해서 사용해야 한다.

function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}

MyApp.getInitialProps = async (appContext: AppContext) => {
  const appProps = await App.getInitialProps(appContext);
  return { ...appProps };
};

export default MyApp;

위의 기본 예제를 변형해서 App에서 컴포넌트의 getInitialPropstitle을 넘겨주도록 해본다. 우선 App.getInitialProps대신 컴포넌트의 getInitialProps를 직접 호출한다. 그리고 title을 넘겨주면 된다. 하지만 타입 검사에서 실패한다.

이유를 알기 위해서는 AppContext를 확인할 필요가 있다.

export declare type AppContext = AppContextType<Router>;

export declare type AppContextType<R extends NextRouter = NextRouter> = {
  Component: NextComponentType<NextPageContext>;
  AppTree: AppTreeType;
  ctx: NextPageContext;
  router: R;
};

AppContextComponent는 위에서 만든 MyPageContext를 사용하지 않고 있다. 이를 위해서는 AppContext대신 별도의 타입을 정의해 주어야 한다. 모두 다시 정의해도 되지만 기존 속성을 활용하기 위해 필요한 속성만 새로 정의한다.

export type MyAppContext = Omit<AppContext, 'Component' | 'ctx'> & {
  Component: MyPage;
  ctx: MyPageContext;
};

그리고 타입을 사용해서 title을 넣어준다.

MyApp.getInitialProps = async (appContext: MyAppContext) => {
  appContext.ctx.title = 'Home';
  const appProps = await App.getInitialProps(appContext);
  return { ...appProps };
};

주의할 점은 App.getInitialProps을 사용하고 MyAppContext에서 ctx의 타입을 변경하면서 appContextctx에도 title이 있는 것으로 잡히기 때문에 실제로 추가 없이 그대로 전달해도 문제가 되지 않는다. _app에서 한 번만 처리하고 순서상으론 반대이기 때문에 누락될 위험이 크진 않지만 이것도 명확하게 해 주기 위해서는 MyAppContext에서 ctx를 그대로 사용하고 아래처럼 해줄 수 있다.

export type MyAppContext = Omit<AppContext, 'Component'> & {
  Component: MyPage;
};

MyApp.getInitialProps = async ({ Component, ctx }: MyAppContext) => {
  const pageProps = await Component.getInitialProps?.({
    ...ctx,
    title: 'Home',
  });

  return { pageProps };
};

실제로 이렇게 까지 사용하는 경우가 얼마나 될지는 모르겠지만, 필요한 경우라면 타입을 명확하게 정의해서 추가적인 검사 코드를 사용하지 않도록 할 수 있어 도움이 되지 않을까 싶다.