Nicolas Bouvrette
Nicolas Bouvrette

Reputation: 4747

How to infer type parameter definitions of a function type in TypeScript?

I'm trying to extend the following Next.js function type:

export type GetStaticProps<
  P extends { [key: string]: any } = { [key: string]: any },
  Q extends ParsedUrlQuery = ParsedUrlQuery,
  D extends PreviewData = PreviewData
> = (
  context: GetStaticPropsContext<Q, D>
) => Promise<GetStaticPropsResult<P>> | GetStaticPropsResult<P>

So that 3 properties on its context (GetStaticPropsContext) and never undefined, like this:

type UpdateContext<T> = T extends (context: infer Context extends GetStaticPropsContext) => infer Return
    ? (context: SelectiveRequiredNotUndefined<Context, "locale" | "locales" | "defaultLocale">) => Return
    : never;

Now if I do this, I can use the new type where those context properties are never undefined:

type MyGetStaticProps = UpdateContext<GetStaticProps>;
const getStaticProps: MyGetStaticProps = async (context) => { 
  // do stuff
}

The problem is that MyGetStaticProps is no longer generic. I know I could always do this instead:

type MyProps = {
    someField: string
};

export const getStaticProps: UpdateContext<GetStaticProps<MyProps>> = async (context) => {
  // do stuff
}

But I was wondering instead if there would not be a way to infer the type parameter definitions instead to avoid the more verbose syntax?

Just to recap, I am looking for a way to have some sort of type MyGetStaticProps = UpdateContext<GetStaticProps>; but that would be supporting MyGetStaticProps<MyProps> (infer the type parameters of GetStaticProps)

Here is a fully working Playground link of the example

Upvotes: 5

Views: 440

Answers (2)

tenshi
tenshi

Reputation: 26307

Unfortunately TypeScript cannot currently represent these kinds of types (known as "higher kinded types"), see microsoft/TypeScript#1213.

You could try and implement them, like this blog post does or this comment shows, but it's clunky and not fully representative of what HKTs can do.

It's probably better to use an alternative, or try to rethink your problem or even prevent it in the first place by redesigning some parts of your code.

In short, you can't really take a type, modify it, then spit the type back out, like you can with JS functions:

// type GetStaticProps<...> = ...;
function GetStaticProps(...) { ... }

// type UpdateContext<Func ...> = ...;
function UpdateContext(Func) {
    const NewFunc = (...args) => {
        args[0].prop = "something else";

        return Func(...args);
    };

    return NewFunc;
}

// type Updated = UpdateContext<GetStaticProps>;
const Updated = UpdateContext(GetStaticProps);

// type Result = Updated<...>; // but Updated can't be a generic, it's a result of another type.
const Result = Updated({ prop: "will change" });

Some HKT comments from jcalz (more in-depth with potential workarounds for you):

Upvotes: 1

Linda Paiste
Linda Paiste

Reputation: 42160

Problem

It took me a while to wrap my head around what you are trying to achieve here, but I think I finally understand that your goals are:

  1. To refine the GetStaticPropsContext such that certain properties of the context are required and non-nullable.
  2. To be able to infer the type of the returned props from the function, rather than defining them yourself.

Resolving #2 by itself -- without #1 -- is simple as Next.js exports an InferGetStaticPropsType<T> utility type for this.

export type InferGetStaticPropsType<T> = T extends GetStaticProps<infer P, any>
  ? P
  : T extends (
      context?: GetStaticPropsContext<any>
    ) => Promise<GetStaticPropsResult<infer P>>
  ? P
  : never

source

The core of your problem is that implementing #1 will make it such that this type no longer works. This is because a function which accepts broader arguments extends a function which accepts a narrower refinement of those arguments, but not the other way around.

An arbitrary illustration of that principle:

// this is `true`
type A = ((arg: number | string) => any) extends ((arg: string) => any) ? true : false;
// this is `false`
type B = ((arg: number) => any) extends ((arg: number | string) => any) ? true : false;

If your getStaticProps function can only accept a refined context type then the InferGetStaticPropsType<T> utility type will no longer work because your getStaticProps no longer extends the basic GetStaticProps.

export const getStaticPropsA = async (context: GetStaticPropsContext) => {
    return {
        props: {
            someField: 'yes'
        }
    }
};

// this is `{ someField: string; }`
type PropsA = InferGetStaticPropsType<typeof getStaticPropsA>


export const getStaticPropsB = async (context: SelectiveRequiredNotUndefined<GetStaticPropsContext, "locale" | "locales" | "defaultLocale">) => {
    return {
        props: {
            someField: 'yes'
        }
    }
};

// this is `never`
type PropsB = InferGetStaticPropsType<typeof getStaticPropsB>

Solution

Since you want to infer the return type, it doesn't make sense to assign a type like GetStaticProps to the entire function. You'll want to just assign a type to the arguments. You've already got that part done with your SelectiveRequiredNotUndefined utility type.

What's missing is the ability to infer the return type. For that, we need to modify some of the core Next.js types.

Approach 1: Module Augmentation

The locale, locales, and defaultLocale properties are marked as optional because not all apps will use internationalization. But if your app includes this routing information in the next.config.js file then it is safe to assume that these properties will always be present.

You can use TypeScript module augmentation to modify the types of the next GetStaticPropsContext type.

import { PreviewData } from 'next';
import { ParsedUrlQuery } from 'querystring';

declare module 'next' {
    export type GetStaticPropsContext<Q extends ParsedUrlQuery = ParsedUrlQuery> = {
        params?: Q
        preview?: boolean
        previewData?: PreviewData
        locale: string
        locales: string[]
        defaultLocale: string
    }
}

This should be the easy solution, but sometimes it can be a pain to set up properly. I thought that we would not have to also override the other types which use this, such as GetStaticProps and InferGetStaticPropsType, but perhaps we do?

This playground has some errors, but it does get the correct types.

Approach 2: Local Types

If augmenting the module is too much of a headache to get working, you can continue what you were already trying which is to create your own modified copies of the Next.js types. Instead of importing the Next.js versions, you would import from our own local types file.

import { GetStaticPropsContext, GetStaticPropsResult } from 'next';
import { ParsedUrlQuery } from 'querystring';

export type MyGetStaticPropsContext<Q extends ParsedUrlQuery = ParsedUrlQuery> =
    GetStaticPropsContext<Q> & Required<Pick<GetStaticPropsContext, 'locale' | 'locales' | 'defaultLocale'>>;

export type MyGetStaticProps<
  P extends { [key: string]: any } = { [key: string]: any },
  Q extends ParsedUrlQuery = ParsedUrlQuery
> = (context: MyGetStaticPropsContext<Q>) => Promise<GetStaticPropsResult<P>>

export type MyInferGetStaticPropsType<T> = T extends MyGetStaticProps<infer P, any>
  ? P
  : T extends (
      context?: MyGetStaticPropsContext<any>
    ) => Promise<GetStaticPropsResult<infer P>>
  ? P
  : never

// ==== Example use:

export const getStaticProps = async (context: MyGetStaticPropsContext) => {
    context.locale
    //       ^? (property) locale: string
    context.locales
    //       ^? (property) locales: string[]
    context.defaultLocale
    //       ^? (property) defaultLocale: string
    context.preview // As an example of a property that doesn't get modified
    //       ^? (property) preview?: boolean | undefined
    return {
        props: {
            someField: 'yes'
        }
    }
};

type MyProps = MyInferGetStaticPropsType<typeof getStaticProps>
//   ^? type MyProps = { someField: string; }

Typescript Playground link

Upvotes: 1

Related Questions