Repository navigation
Description
Search Terms
TypeScript 7, tsgo, memory, out of memory, next-intl, NamespaceKeys, TS2589, TS2590
Version & Regression Information
typescript@7.0.2grows without bound.typescript@6.0.3andtypescript@5.9.3stay 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 thent("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 (
key0tokey24) 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
Stop unbounded memory growth after type complexity limits are hit
Stop unbounded memory growth after type complexity limits are hit
Keviniscool-boy commentedon Nov 5, 2026
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
tsgofrommain, Linux x64 via WSL): the same four diagnostics (TS2345, TS2590, TS2345, TS2589) and unbounded growth.Scaling (peak RSS,
/usr/bin/time -f %M):Heap profile (
--pprofDir, inuse objects): distinctgetStringLiteralTypeobjects 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 undergetTemplateLiteralType(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):
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 thestringLiteralTypes/templateLiteralTypescaches.Two observations from attempts to bound it:
checkCrossProductUnionbounds each individualgetTemplateLiteralTypecall 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 (getCrossProductUnionSizerecursion, mirroring whataddSpansflattens) — it did not reduce the memory, i.e. the multiplication here is spread across many distinct calls rather than nested inside one.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 oft("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.