Skip to content

TypeScript 7 memory grows without bound on a next-intl call with a missing namespace and ICU arguments (6.0 stays at 2.5 GB) #65095

Open

Description

Search Terms

TypeScript 7, tsgo, memory, out of memory, next-intl, NamespaceKeys, TS2589, TS2590

Version & Regression Information

  • typescript@7.0.2 grows without bound. typescript@6.0.3 and typescript@5.9.3 stay under 2.5 GB on the same program.
  • Others: next-intl@4.13.4, next@16.4.3, react@19.2.3, @types/react@19.2.2, Linux x64.

Playground Link

No response (the repro needs a generated JSON file and next-intl)

Code

package.json:

{ "name": "tsgo-next-intl-repro", "private": true, "type": "module",
  "dependencies": { "next": "16.4.3", "next-intl": "4.13.4", "react": "19.2.3", "react-dom": "19.2.3" },
  "devDependencies": { "@types/react": "19.2.2", "typescript": "7.0.2", "typescript6": "npm:typescript@6.0" } }

tsconfig.json:

{ "compilerOptions": { "target": "ES2022", "module": "esnext", "moduleResolution": "bundler", "jsx": "react-jsx",
    "strict": true, "noEmit": true, "skipLibCheck": true, "resolveJsonModule": true }, "include": ["src"] }

src/next-intl.d.ts:

import type messages from "./messages.json";
declare module "next-intl" { interface AppConfig { Messages: typeof messages } }

src/probe.tsx uses a namespace that does not exist, and calls the translator with ICU arguments:

import { useTranslations } from "next-intl";
export function Probe({ name }: { name: string }) { const t = useTranslations("group0.missing"); return <p>{t("opensAt", { day: name })}</p>; }

gen.py writes src/messages.json with N messages (python3 gen.py 1000):

import json, random, sys
# Groups of 21 namespaces of 25 messages, nested up to depth 4, a unique key name
# for every message, and about 14% of messages with ICU arguments.
random.seed(1)
total = int(sys.argv[1]); cat = {}; n = 0; g = 0
def word():
    return "".join(random.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(random.randint(4, 10)))
while n < total:
    group = {}
    for i in range(21):
        ns = {}
        for j in range(25):
            node = ns
            for d in range(random.choice([0, 0, 1, 1, 2, 3])):
                node = node.setdefault(word(), {})
            node[word() + str(n)] = f"Text {n}" if random.random() > 0.14 else "{count, plural, one {# item for {name}} other {# items for {name}}}"
            n += 1
        group[word() + str(i)] = ns
    cat[f"group{g}"] = group; g += 1
json.dump(cat, open("src/messages.json", "w"))

Run npx tsc -p . (7.0.2) and node node_modules/typescript6/bin/tsc -p . (6.0.3).

Actual behavior

Both versions report the same four errors:

src/probe.tsx(2,79): error TS2345: Argument of type '"group0.missing"' is not assignable to parameter of type 'NamespaceKeys<...>'.
src/probe.tsx(2,109): error TS2590: Expression produces a union type that is too complex to represent.
src/probe.tsx(2,111): error TS2345: Argument of type '"opensAt"' is not assignable to parameter of type 'NamespacedMessageKeys<...>'.
src/probe.tsx(2,122): error TS2589: Type instantiation is excessively deep and possibly infinite.

But 7.0.2 needs far more memory, and its memory grows much faster than the catalog. I measured peak resident memory with /usr/bin/time -f %M, under a 16 GB cgroup cap with no swap:

Messages 6.0.3 7.0.2
500 (5.9.3: 0.75 GB, 8 s) 1.6 GB, 4 s
1,000 2.0 GB, 22 s 6.1 GB, 14 s
2,000 2.5 GB, 51 s OOM-killed at 16 GB

In a Next.js app with a 14,000-message catalog, one such call made tsc --noEmit grow to 40 GB, and it had to be killed. TypeScript 5.9 checks that app in 9.2 GB. On a machine with swap, 7.0.2 thrashes instead of failing, and the whole machine stalls.

Expected behavior

Memory use close to 6.0's for the same program. At least, 7.0.2 should stop once it has reported TS2589 and TS2590, as 6.0 appears to.

Additional information about the issue

  • With an existing namespace and a missing key (useTranslations("group0.<existing>") and then t("opensAt", { day })), both versions finish in under 1 s with less than 0.5 GB.
  • Without ICU arguments (t("opensAt") in the missing namespace), 7.0.2 stays small.
  • Key names matter. The same catalog with key names reused in every namespace (key0 to key24) peaks at 3.8 GB in 7.0.2 and 1.4 GB in 6.0 at 14,000 messages. It does not grow without bound.
  • The number of calls does not matter: 1, 2 and 4 calls in the probe all peak at about 3.8 GB in that reused-name catalog.

Activity

  1. Keviniscool-boy commented on Nov 5, 2026

    @Keviniscool-boy

    I reproduced this with the reporter's exact repro and did some profiling that localizes where the memory goes. Posting in case it helps whoever picks this up.

    Reproduction confirmed (self-built tsgo from main, Linux x64 via WSL): the same four diagnostics (TS2345, TS2590, TS2345, TS2589) and unbounded growth.

    Scaling (peak RSS, /usr/bin/time -f %M):

    Messages typescript 6.0.3 tsgo (main)
    500 673 MB —
    1,000 1,914 MB 2,650 MB
    2,000 2,500 MB >7 GB (killed at the WSL cap)

    Heap profile (--pprofDir, inuse objects): distinct getStringLiteralType objects grow 2.0M (500 msgs) → 7.3M (1000 msgs) — roughly quadratic, ~50% of all live objects. The retained bytes are dominated by string literal types created under getTemplateLiteralType (the ICU templates in the message catalog), and the profile is dominated by GC scanning that live set.

    Call path that drives it (CPU profile, ~95% of checker time):

    checkExpressionWithContextualType        (contextual typing of the ICU args object literal { day: name })
    → getResolvedBaseConstraint
    → computeBaseConstraint                  (94.65% of it)
    → getConstraintFromConditionalType       (94.65%)
    → getConstraintOfDistributiveConditionalType
       ├─ getConditionalTypeInstantiation    (~50%)
       └─ getConstraintOfType                (~50%)
    → … → mapType → getTemplateLiteralType → getStringLiteralType
    

    i.e. the base-constraint computation of the contextual type walks next-intl's recursive conditional/template types (NamespaceKeys / NamespacedMessageKeys) and distributes template literal cross products while resolving the conditional for each member; every created literal/template type is retained for the program lifetime in the stringLiteralTypes / templateLiteralTypes caches.

    Two observations from attempts to bound it:

    1. checkCrossProductUnion bounds each individual getTemplateLiteralType call to <100k combinations, but the products compound across separate calls (per union member × per conditional instantiation), so no single call sees the full product. I tried accounting for union factors hidden in nested template spans (getCrossProductUnionSize recursion, mirroring what addSpans flattens) — it did not reduce the memory, i.e. the multiplication here is spread across many distinct calls rather than nested inside one.

    2. Both conditional constraint caches (resolvedConstraintOfDistributive, resolvedDefaultConstraint) are memoized per conditional type object, and this repro creates a distinct conditional object per key/instantiation level, so nothing is shared across the union members' property lookups.

    Without the ICU-args second argument (t("opensAt") instead of t("opensAt", { day: name })) the same program peaks at ~81 MB, so the explosion is specific to contextually typing the object literal against the ICU-message-derived union.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugA bug in TypeScript

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions