Typing components in Next.JS applications
Published on May 27, 2019Note: This post covers typing Next.JS applications that use versions prior to v9. Starting with version 9, Next.JS comes with its own types by default, and their names may differ from those used in DefinitelyTyped package. If you use Next.JS version 9 and older, please refer to the official documentation. For earlier versions, continue reading :)
In this article, we’ll talk about typing Next.JS components. We’ll be using this Next.JS application that connects to Reddit API and displays a list of top posts in a given subreddit. Right now, the main component Posts.tsx
doesn’t have any type-safety, so we’re going to fix that.
The project already contains TypeScript configured, so if you don’t know how to do that, please read the official guide here, it’s as easy as installing a few dependencies and dropping in some configuration files.
Now let’s clone the project from GitHub and get started.
FunctionComponent
First, let’s take a look at how we type React components in general.
If we write our components as functions, we can use FunctionComponent
type from React library. This type takes a generic type argument that describes the shape of the props. Our Posts
component takes a subreddit name and a list of posts, so props
object is going to look like this:
import React, { FunctionComponent } from 'react' | |
type Props = { | |
posts: readonly RedditPost[] | |
subreddit: string | |
} | |
const Posts: FunctionComponent<Props> = ({ posts, subreddit }) => ( | |
<div> | |
<h1>Posts in "{subreddit}"</h1> | |
<ul> | |
{posts.map(post => ( | |
<li key={post.data.id}>{post.data.title}</li> | |
))} | |
</ul> | |
</div> | |
) |
Now when we destructure props into posts
and subreddit
, we get full type safety. Pretty neat, right?
Now let’s look at Next.JS components.
NextFunctionComponent
One thing that makes Next.JS components different is a static getInitialProps
function. If we try to assign it to our regular React component, we’ll get a type error:
/* | |
* ERROR: Property 'getInitialProps' does not exist on type 'FunctionComponent<Props>'. | |
*/ | |
Posts.getInitialProps = async () => { | |
const subreddit = 'typescript' | |
const response = await fetch(`https://www.reddit.com/r/${subreddit}.json`) | |
const result = await response.json() as RedditResult | |
return { | |
subreddit, | |
posts: result.data.children | |
} | |
} |
To fix this problem, we need to use a special component type from Next.JS package called NextFunctionComponent
. This type extends standard React’s FunctionComponent
type with Next.JS-specific static lifecycle methods (well, only one method, really). So now our code will look like this:
import React from 'react' | |
import fetch from 'isomorphic-fetch' | |
import { NextFunctionComponent } from 'next' | |
type Props = { | |
posts: readonly RedditPost[] | |
subreddit: string | |
} | |
// 1. We use NextFunctionComponent type here | |
const Posts: NextFunctionComponent<Props> = ({ posts, subreddit }) => ( | |
<div> | |
<h1>Posts in "{subreddit}"</h1> | |
<ul> | |
{posts.map(post => ( | |
<li key={post.data.id}>{post.data.title}</li> | |
))} | |
</ul> | |
</div> | |
) | |
// 2. This no longer causes a type error | |
Posts.getInitialProps = async () => { | |
const subreddit = 'typescript' | |
const response = await fetch(`https://www.reddit.com/r/${subreddit}.json`) | |
const result = await response.json() as RedditResult | |
return { | |
subreddit, | |
posts: result.data.children | |
} | |
} |
To make our types more robust, we can infer the shape of props
returned from getInitialProps
function instead of defining them manually. To do that, first, we want to extract getInitialProps
function into a separate variable. This step is required to avoid circular type reference when we start inferring the shape of our props:
const getInitialProps = async () => { | |
const subreddit = 'typescript' | |
const response = await fetch(`https://www.reddit.com/r/${subreddit}.json`) | |
const result = await response.json() as RedditResult | |
return { | |
subreddit, | |
posts: result.data.children | |
} | |
} | |
Posts.getInitialProps = getInitialProps |
Next, we can use ReturnType
helper type to get the type of the value returned from getInitialProps
function:
type Props = ReturnType<typeof getInitialProps> | |
/* | |
type Props = Promise<{ | |
subreddit: string; | |
posts: readonly RedditPost[]; | |
}> | |
*/ |
Since getInitialProps
function is asynchronous, the return type is going to be a promise, so we also need to extract its value. We can define a global helper type that will use conditional type magic to unwrap our promise:
declare type PromiseResult<T> = T extends Promise<infer U> ? U : T |
Now we can put everything together:
import React from 'react' | |
import fetch from 'isomorphic-fetch' | |
import { NextFunctionComponent } from 'next' | |
type Props = PromiseResult<ReturnType<typeof getInitialProps>> | |
const Posts: NextFunctionComponent<Props> = ({ posts, subreddit }) => ( | |
<div> | |
<h1>Posts in "{subreddit}"</h1> | |
<ul> | |
{posts.map(post => ( | |
<li key={post.data.id}>{post.data.title}</li> | |
))} | |
</ul> | |
</div> | |
) | |
const getInitialProps = async () => { | |
const subreddit = 'typescript' | |
const response = await fetch(`https://www.reddit.com/r/${subreddit}.json`) | |
const result = await response.json() as RedditResult | |
return { | |
subreddit, | |
posts: result.data.children | |
} | |
} | |
Posts.getInitialProps = getInitialProps |
NextContext
Let’s make this example more interesting by taking the name of a subreddit from a query string.
To achieve that, we can use a context
argument that gets passed to getInitialProps
function that we haven’t used so far. We will use NextContext<T>
type to type this argument. The type T
allows us to specify the parameters we know we will have in a query string (subreddit
in our case):
import { NextContext } from 'next' | |
// we define our type | |
type Context = NextContext<{ subreddit: string }> | |
// ... | |
const getInitialProps = async (context: Context) => { | |
// now we can get subreddit value from the query! | |
const subreddit = context.query.subreddit | |
const response = await fetch(`https://www.reddit.com/r/${subreddit}.json`) | |
const result = await response.json() as RedditResult | |
return { | |
subreddit, | |
posts: result.data.children | |
} | |
} | |
/* | |
* But we get an error here :( | |
* | |
* Type '(context: NextContext<{ subreddit: string; }, {}>) => | |
* Promise<{ subreddit: string; posts: readonly RedditPost[]; }>' is not assignable to type | |
* 'GetInitialProps<{ subreddit: string; posts: readonly RedditPost[]; }, | |
* NextContext<Record<string, string | string[] | undefined>, {}>>'. | |
*/ | |
Posts.getInitialProps = getInitialProps |
We got type-safety inside getInitialProps
function, but now we run into another type error talking about incompatible types of Context
.
The reason is that by default getInitialProps
expects a context to be of a generic type NextContext
, but we specified a stricter, more specific type — NextContext<{ subreddit: string }
. To resolve this issue, we need to pass a few more type arguments to NextFunctionComponent
type. Its full signature looks like this:
type NextFunctionComponent<Props = {}, InitialProps = Props, Context = NextContext> |
As you can see, NextFunctionComponent
can take up to 3 types — Props
, InitialProps
, and Context
. InitialProps
should only contain props returned from getInitialProps
function. Props
should contain all the props that component has access to, which include own component props, props passed through higher order components (such as connect
from Redux), plus the initial props. And finally, Context
specifies the shape of the context used by our component.
When we put everything together, we’ll get a fully typed Next.JS component
import React from 'react' | |
import fetch from 'isomorphic-fetch' | |
import { NextFunctionComponent, NextContext } from 'next' | |
type InitialProps = PromiseResult<ReturnType<typeof getInitialProps>> | |
type Props = InitialProps | |
type Context = NextContext<{ subreddit: string }> | |
const Posts: NextFunctionComponent<Props, InitialProps, Context> = ({ posts, subreddit }) => ( | |
<div> | |
<h1>Posts in "{subreddit}"</h1> | |
<ul> | |
{posts.map(post => ( | |
<li key={post.data.id}>{post.data.title}</li> | |
))} | |
</ul> | |
</div> | |
) | |
const getInitialProps = async (context: Context) => { | |
const subreddit = context.query.subreddit | |
const response = await fetch(`https://www.reddit.com/r/${subreddit}.json`) | |
const result = await response.json() as RedditResult | |
return { | |
subreddit, | |
posts: result.data.children | |
} | |
} | |
Posts.getInitialProps = getInitialProps |
You can find the source code for the fully typed component in “final” branch.
Conclusion
In this article, we’ve explored how to type Next.JS components. The approach is different from typing regular React functional components because of the special getInitialProps
function that Next.JS uses to prepare props data server-side. For that reason, we need to use special NextFunctionComponent
and NextContext
types that come with Next.JS typing package.
PS: If you’re curious why we used type aliases everywhere instead of interfaces, make sure to check this article.