Complex TypeScript type causes: Type instantiation is excessively deep and possibly infinite.ts(2589)

1.3k Views Asked by At

I have a nested TypeScript type created with function composition.

export const billingLoader = asyncPipe(
  withOrganizationMembership,
  withTFunction,
  withPageTitle({ tKey: 'billing:billing' }),
  withHeaderProps({ headerTitleKey: 'billing:billing' }),
  withBillingMiddleware,
  withBillingPageProps,
  json,
);

My asyncPipe looks like this:

type Callback = (a: any) => MaybePromise<unknown>;
type FunToReturnType<F> = F extends Callback
  ? ReturnType<F> extends Promise<infer U>
    ? U
    : ReturnType<F>
  : never;
type EmptyPipe = (a: never) => Promise<never>;

type AsyncPipeReturnType<
  FS extends Callback[],
  P = Parameters<FS[0]>[0],
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
> = FS extends [...infer _, infer Last]
  ? (a: P) => Promise<FunToReturnType<Last>>
  : EmptyPipe;

type AsyncParameters<
  FS extends Callback[],
  P = Parameters<FS[0]>[0],
> = FS extends [infer H, ...infer Rest]
  ? H extends (p: P) => unknown
    ? Rest extends Callback[]
      ? [H, ...AsyncParameters<Rest, FunToReturnType<H>>]
      : [{ error: '__A_PARAMETER_NOT_A_FUNCTION__' }, ...Rest]
    : [
        { error: '__INCORRECT_FUNCTION__'; provided: H; expected_parameter: P },
        ...Rest,
      ]
  : FS;

type MaybePromise<T> = T | Promise<T>;

export function asyncPipe<A, B>(
  ab: (a: A) => MaybePromise<B>,
): (a: A) => Promise<B>;
export function asyncPipe<A, B, C>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
): (a: A) => Promise<C>;
export function asyncPipe<A, B, C, D>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>,
): (a: A) => Promise<D>;
export function asyncPipe<A, B, C, D, E>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>,
  de: (d: D) => MaybePromise<E>,
): (a: A) => Promise<E>;
export function asyncPipe<A, B, C, D, E, F>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>,
  de: (d: D) => MaybePromise<E>,
  // eslint-disable-next-line unicorn/prevent-abbreviations
  ef: (e: E) => MaybePromise<F>,
): (a: A) => Promise<F>;
export function asyncPipe<A, B, C, D, E, F, G>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>,
  de: (d: D) => MaybePromise<E>,
  // eslint-disable-next-line unicorn/prevent-abbreviations
  ef: (e: E) => MaybePromise<F>,
  fg: (f: F) => MaybePromise<G>,
): (a: A) => Promise<G>;
export function asyncPipe<A, B, C, D, E, F, G, H>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>,
  de: (d: D) => MaybePromise<E>,
  // eslint-disable-next-line unicorn/prevent-abbreviations
  ef: (e: E) => MaybePromise<F>,
  fg: (f: F) => MaybePromise<G>,
  gh: (g: G) => MaybePromise<H>,
): (a: A) => Promise<H>;
export function asyncPipe<A, B, C, D, E, F, G, H, I>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>,
  de: (d: D) => MaybePromise<E>,
  // eslint-disable-next-line unicorn/prevent-abbreviations
  ef: (e: E) => MaybePromise<F>,
  fg: (f: F) => MaybePromise<G>,
  gh: (g: G) => MaybePromise<H>,
  hi: (h: H) => MaybePromise<I>,
): (a: A) => Promise<I>;
export function asyncPipe<A, B, C, D, E, F, G, H, I, J>(
  ab: (a: A) => MaybePromise<B>,
  bc: (b: B) => MaybePromise<C>,
  cd: (c: C) => MaybePromise<D>,
  de: (d: D) => MaybePromise<E>,
  // eslint-disable-next-line unicorn/prevent-abbreviations
  ef: (e: E) => MaybePromise<F>,
  fg: (f: F) => MaybePromise<G>,
  gh: (g: G) => MaybePromise<H>,
  hi: (h: H) => MaybePromise<I>,
  // eslint-disable-next-line unicorn/prevent-abbreviations
  ij: (i: I) => MaybePromise<J>,
): (a: A) => Promise<J>;

export function asyncPipe<FS extends any[]>(
  ...fns: AsyncParameters<FS>
): AsyncPipeReturnType<FS>;
export function asyncPipe(...fns: AsyncParameters<any[]>) {
  if (fns.length === 0) return () => Promise.resolve();
  // eslint-disable-next-line prettier/prettier
  return (x: Parameters<(typeof fns)[0]>[0]) =>
    fns.reduce(async (y, function_) => function_(await y), x);
}

When I use the function, I get the error:

Type instantiation is excessively deep and possibly infinite.ts(2589)

However, this type is not infinite. Is there anyway to modify the language server to increase how deep it goes when following a composed function?

I've read somewhere it goes 50 level deep and if that value could be increased, that would solve my problem.

I've tried finding how to increase the depthness of TypeScript's checks, but couldn't find anything so I'm asking on SO how this problem could be fixed.

Ideally, I would NOT like to ignore this message (as other similar questions on SO suggested) because I know my types resolve correctly and TypeScript is just "too lazy".


Edit for wonderflame

Here are the functions used in the composition:

import type { LoaderArgs } from '@remix-run/node';

import { requireUserIsAuthenticated } from './user-authentication-session.server';

export const withAuth = async <
  T extends Pick<LoaderArgs, 'request' | 'params'> & { redirectTo?: string },
>({
  redirectTo,
  request,
  ...rest
}: T) => ({
  redirectTo,
  request,
  userId: await requireUserIsAuthenticated(request, redirectTo),
  ...rest,
});
import type { UserWithOrganizations } from './user-profile-helpers.server';
import {
  requireUserProfileWithOrganizationsExists,
  throwIfUserLacksNameOrOrganization,
} from './user-profile-helpers.server';

export const withUser = async <T extends { request: Request; userId: string }>({
  userId,
  request,
  ...rest
}: T) => ({
  request,
  userId,
  user: await requireUserProfileWithOrganizationsExists(request, userId),
  ...rest,
});

/**
 * Ensures that the user has a name and is a member of any organization.
 *
 * @param object - A middleware object that contains the user.
 * @returns The same object if the user has a name and is a member of any
 * organization.
 * @throws A redirection to '/onboarding' if the user does not have a name or is
 * not a member of any organization.
 */
export const withUserHasNameAndOrganization = <
  T extends { user: UserWithOrganizations },
>({
  user,
  ...rest
}: T) => ({ user: throwIfUserLacksNameOrOrganization(user), ...rest });
type Params = LoaderArgs['params'];

/**
 * Extracts the organization slug from the provided parameters.
 *
 * @param params - An object containing various parameters from Remix including
 * the organization slug.
 * @returns The organization slug if it exists, otherwise an empty string.
 */
export const getOrganizationSlug = (params: Params) =>
  params.organizationSlug ?? '';

/**
 * Enriches an existing middleware object with an organization slug.
 *
 * @param middleware - A middleware object that contains parameters.
 * @returns A new middleware object with the same properties as the input
 * object, plus an organization slug, which defaults to an empty string.
 */
export const withOrganizationSlug = <T extends { params: Params }>({
  params,
  ...rest
}: T) => ({
  ...rest,
  params,
  organizationSlug: getOrganizationSlug(params),
});

/**
 * Ensures that the provided organization exists in the database.
 *
 * @param organizationSlug - The slug of the organization to retrieve.
 * @returns The organization retrieved from the database.
 * @throws A '404 not found' HTTP response if the organization doesn't exist.
 */
const requireOrganizationBySlugExists = asyncPipe(
  retrieveOrganizationFromDatabaseBySlug,
  throwIfEntityIsMissing,
);

/**
 * Enriches an existing middleware object with an organization retrieved by
 * its slug.
 *
 * @param object - A middleware object that contains the organization slug.
 * @returns A new middleware object with the same properties as the input
 * object, plus an organization retrieved from the database using the slug.
 * @throws A '404 not found' HTTP response if the organization doesn't exist.
 */
const withOrganizationBySlug = async <T extends { organizationSlug: string }>({
  organizationSlug,
  ...rest
}: T) => ({
  ...rest,
  organizationSlug,
  organization: await requireOrganizationBySlugExists(organizationSlug),
});


/**
 * Ensures that the user is a member of the provided organization.
 *
 * @param object - A middleware object that contains the user and the
 * organization.
 * @returns The same object if the user is a member of the organization.
 * @throws A '404 not found' HTTP response if the user is not a member of the
 * organization.
 */
export const withUserIsMemberOfOrganization = <
  T extends {
    user: UserWithOrganizations;
    organization: Organization;
  },
>({
  user,
  organization,
  ...rest
}: T) => {
  if (!getOrganizationIsInUserMembershipList(organization.id, user)) {
    throw notFound();
  }

  return { user, organization, ...rest };
};


/**
 * A middleware for ensuring the request contains an authenticated user
 * who is a member of the organization with the slug in the request.
 *
 * @param middleware - An object that contains the `LoaderArgs`
 * parameters from Remix.
 * @returns A new middleware object with the user's id, the user profile and
 * the organization of the given slug, if the user is a member of that
 * organization.
 * @throws A redirect and logs the user out, if the user is not authenticated.
 * @throws A '404 not found' HTTP response if the user profile does not exist,
 * the organization does not exist, or the user is not a member of the
 * organization.
 */
export const withOrganizationMembership = asyncPipe(
  withAuth,
  withUser,
  withUserHasNameAndOrganization,
  withOrganizationSlug,
  withOrganizationBySlug,
  withUserIsMemberOfOrganization,
);
/**
 * Adds an i18next translation function to the middleware object.
 *
 * @param middleware - The middleware object with a request.
 * @returns The middleware object with a translation function.
 */
export const withTFunction = async <T extends { request: Request }>({
  request,
  ...rest
}: T) => ({
  request,
  ...rest,
  t: await i18next.getFixedT(request),
});

type $Dictionary<T = any> = { [key: string]: T };

/**
 * Adds a page title to the middleware object.
 *
 * @param tKey - Translation key or key options pair to add a translated prefix
 * title.
 * @param prefix - A custom prefix to add to the title.
 * @param middleware - The middleware object with a translation function.
 * @returns The middleware object with a page title.
 */
export const withPageTitle =
  ({
    tKey = '',
    prefix = '',
  }: {
    tKey?:
      | string
      | {
          tKey: string;
          options: TOptions<$Dictionary>;
        };
    prefix?: string;
  } = {}) =>
  async <T extends { t: TFunction }>({ t, ...rest }: T) => ({
    t,
    ...rest,
    pageTitle: getPageTitle(t, tKey, prefix),
  });
/**
 * A middleware for adding the header title props for the organization sidebar.
 *
 * @param headerTitleKey - The key for the header title.
 * @param renderBackButton - Whether to render the back button.
 * @param middleware - An object that contains the `t` function from
 * React-i18next.
 * @returns A new middleware object with the header title props.
 */
export const withHeaderProps =
  ({
    headerTitleKey,
    renderBackButton = false,
  }: {
    headerTitleKey: string;
    renderBackButton?: boolean;
  }) =>
  <T extends { t: TFunction }>({ t, ...rest }: T) => ({
    t,
    ...rest,
    headerTitle: t(headerTitleKey),
    renderBackButton,
  });
/**
 * Middleware to retrieve the latest Stripe subscription for an organization.
 *
 * @param middleware - An object that contains the organization.
 * @returns A new middleware object with the latest Stripe subscription for the
 * organization.
 */
export const withStripeSubscription = async <
  T extends { organization: Organization },
>(
  middleware: T,
) =>
  Object.assign(middleware, {
    stripeSubscription:
      await retrieveLatestStripeSubscriptionFromDatabaseByOrganizationId(
        middleware.organization.id,
      ),
  });

/**
 * Middleware to retrieve the total call count during the period for an
 * organization.
 *
 * @param middleware - An object that contains the organization and Stripe
 * subscription.
 * @returns A new middleware object with the total call count during the period
 * for an organization.
 */
export const withCallCountDuringPeriod = async <
  T extends {
    organization: Organization;
    stripeSubscription: StripeSubscription | null;
  },
>(
  middleware: T,
) =>
  Object.assign(middleware, {
    callCountDuringPeriod:
      (await retrieveCallCountForCurrentPeriodOrAllTimeByOrganization({
        organization: middleware.organization,
        stripeSubscription: middleware.stripeSubscription,
      })) +
      (await maybeRetrieveCloseDotComCallCountForPeriodByOrganizationId({
        organization: middleware.organization,
        stripeSubscription: middleware.stripeSubscription,
      })),
  });

/**
 * Middleware to check whether the usage limit has been reached for an
 * organization.
 *
 * @param middleware - An object that contains the organization, Stripe
 * subscription and call count during the period.
 * @returns A new middleware object with a boolean indicating if the usage limit
 * has been reached for the organization.
 */
export const withHasReachedUsageLimit = <
  T extends {
    organization: Organization;
    stripeSubscription: StripeSubscription | null;
    callCountDuringPeriod: number;
  },
>(
  middleware: T,
) =>
  Object.assign(middleware, {
    hasReachedUsageLimit: getHasReachedUsageLimit({
      callCountDuringPeriod: middleware.callCountDuringPeriod,
      stripeSubscription: middleware.stripeSubscription,
      organization: middleware.organization,
    }),
  });

/**
 * Middleware to handle billing related operations.
 *
 * @param middleware - An object that contains the organization.
 * @returns A new middleware object with the latest Stripe subscription, total
 * call count during period and a boolean indicating whether the usage limit has
 * been reached for the organization.
 */
export const withBillingMiddleware = asyncPipe(
  withStripeSubscription,
  withCallCountDuringPeriod,
  withHasReachedUsageLimit,
);

/**
 * Middleware to map billing related data to page properties and attach to
 * middleware.
 *
 * @param middleware - The middleware object with call count and Stripe
 * subscription.
 * @returns A new middleware object with the mapped data for billing page props.
 */
export const withBillingPageProps = <
  T extends {
    callCountDuringPeriod: number;
    stripeSubscription: StripeSubscription | null;
  },
>(
  middleware: T,
) => Object.assign(middleware, mapStripeDataToBillingPageProps(middleware));
import { json, redirect } from '@remix-run/node';
export async function loader({ request, params }: LoaderArgs) {
  return await billingLoader({ request, params });
}
1

There are 1 best solutions below

0
jcalz On

No, there is no way to configure TypeScript to increase the type instantiation depth limit so that you can avoid the "Type instantiation is excessively deep and possibly infinite" error.

This lack of user-configurability is intentional. Requests for this have been consistently declined; see microsoft/TypeScript#29602, microsoft/TypeScript#44997, and microsoft/TypeScript#46180.

As mentioned in this comment in microsoft/TypeScript#44997 by the TS team dev lead:

I can't stress enough that we don't think this is appropriate to expose as configuration. Today instantiation is a process with a particular memory and runtime cost that occurs during certain operations, but every aspect of that is an implementation detail that could change tomorrow, at which point the numeric limits specified at any given point in time do not translate to the same behavior any more.

This isn't a fix for the linked issue and, again, we do not consider this to be a "tunable" parameter.

Indeed the original depth limit of 50 was increased to 500 (see microsoft/TypeScript#45025, which caused compiler stack overflow in the wild, unfortunately. So it was then lowered to 100 with a exception for tail-recursive conditional types which have a higher limit of 1000 (see microsoft/TypeScript#45711).

So that's where things stand. The TS team takes the position that you should rewrite your types so that they do not require type instantiations more than 100 levels deep, or 1000 levels deep if they are tail-recursive conditional types. There are various techniques to do this (e.g., if all else fails you can try writing a depth limiter manually so that MyRecursiveType<T> becomes MyRecursiveType<T, D extends number=50> and your type explicitly bails out after recursing D times) but they're out of scope for the question as asked.