Skip to content

bun check - a TypeScript type checker builtin to Bun - #44361

Open
Jarred-Sumner wants to merge 291 commits into
mainfrom
claude/bun-check
Open

Jarred-Sumner wants to merge 291 commits into
mainfrom
claude/bun-check

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

This adds a TypeScript type checker to Bun.

bun check                        # type check the project
bun --check src/index.ts         # type check, then run
bun build --check src/index.ts   # type check, then bundle
bun test --check                 # type check, then run the tests

It's a port of the type checker in typescript-go 7.0.2. You get the same errors as tsc, with the same error codes, messages, lines and columns. If bun check and tsc 7 disagree about an error, that's a bug in Bun.

packages/next in the Next.js repo, 2,881 files:

CPU time Peak memory
tsc 6.0.2 14.8 s 2.33 GB
tsc 7.0.2 (typescript-go) 6.9 s 1.78 GB
bun check 2.4 s 0.69 GB

(How that was measured is under Performance.)

It only type checks. It doesn't emit JavaScript, .d.ts files, sourcemaps or .tsbuildinfo. There's no language server, so your editor keeps using TypeScript.

Most of this description is about how we tested it. What's still wrong is at the bottom.

Usage

Check a project

bun check looks for the nearest tsconfig.json and checks whatever files, include and exclude select. It uses all of your CPU cores.

$ bun check
src/index.ts(3,25): error TS2322: Type 'string' is not assignable to type 'number'.
Found 1 error in 1 file, checked 2 files [14.00ms]
$ echo $?
1
$ bun check
✓ No type errors in 2 files [14.00ms]
$ echo $?
0

Errors go to stdout. The summary goes to stderr.

If your package.json already has a check script, bun check still runs that script, like it does today. bun --check with no file is the type checker in every project. "check": "bun check" works too: inside that script, and in the scripts it runs, bun check is the type checker.

bun --check

bun --filter '*' check and bun --workspaces check still run each package's check script.

Check some files

bun check src/index.ts
bun check src/server packages/shared

Bun checks those files and everything they import. Each file is checked in the project your editor uses for it. That's the nearest tsconfig.json, or, if that one doesn't include the file but has references, the referenced project that does. So it works in the layout create vite gives you.

A directory means the part of the project that's in it, so bun check . is the same as bun check. If the project has no files there, like bun check scripts when include is ["src"], Bun checks every file in the directory that isn't excluded, each with the tsconfig.json nearest to it.

Choose a tsconfig

bun check -p packages/server
bun check -p tsconfig.test.json

Without a tsconfig

bun check index.ts works in an empty directory. These are the defaults:

{
  "lib": ["ESNext"], "target": "ESNext", "module": "Preserve", "moduleDetection": "force",
  "jsx": "react-jsx", "allowJs": true, "moduleResolution": "bundler",
  "allowImportingTsExtensions": true, "verbatimModuleSyntax": true,
  "noEmit": true, "strict": true, "skipLibCheck": true
}

In a monorepo with no tsconfig.json at the root, bun check checks everything below the current directory. Each file gets the options of the tsconfig.json nearest to it, and each file is only checked once.

Project references

references are followed like tsc -b does, except you don't have to build anything first and nothing gets written to disk. Each project is checked with its own compiler options. Errors are printed one project at a time, in build order. A file that 2 projects include is checked in both, and its errors are printed once for each, like tsc -b does.

$ bun check
packages/app/src/index.ts(2,14): error TS2322: Type 'number' is not assignable to type 'string'.
Found 1 error in 1 file, checked 176 files across 2 projects [544.00ms]

--check

--check type checks the entry point and everything it imports before doing anything else. If there's a type error, Bun prints it, exits with code 1, and your code doesn't run.

$ bun --check src/good.ts
hello Ada

$ bun --check src/bad.ts
src/bad.ts(3,21): error TS2322: Type 'string' is not assignable to type 'number'.
Found 1 error in 1 file, checked 2 files
$ echo $?
1

With --watch, every restart is checked first. After a type error Bun waits for the next change in any file of the program, including files that only have types. --hot --check restarts the process like --watch does, so the code is checked again before it runs.

A file without an extension, -e, -p and a script on stdin are checked as TypeScript, which is how Bun runs them. If a file imports an HTML page, the scripts of that page are checked too. --conditions and --loader apply to the check: with --loader .js:ts, every .js file of your project is TypeScript.

--check doesn't cover files loaded with --preload. bun check does, if your tsconfig.json includes them.

bun --watch --check src/index.ts
bun test --watch --check
$ bun build --check src/good.ts --outdir out
Bundled 2 modules

  good.js  131 bytes  (entry point)

$ bun build --check src/bad.ts --outdir out
3 | console.log(greet({ age: "36" }));
                        ^
error: TS2322: Type 'string' is not assignable to type 'number'.
    at /app/src/bad.ts:3:21

1 | export function greet(user: { name?: string; age: number }) {
                                                 ^
note: TS6500: The expected type comes from property 'age' which is declared here on type '{ name?: string | undefined; age: number; }'
   at /app/src/greet.ts:1:46
$ ls out
ls: out: No such file or directory
$ bun test --check
src/bad.test.ts(3,39): error TS2322: Type 'string' is not assignable to type 'number'.
Found 1 error in 1 file, checked 3 files

A package.json script has no entry point to start from, so Bun checks the whole project first, like bun check. Same with --filter, --parallel and --sequential.

bun run --check dev
bun --check --filter '*' build

--tsconfig-override applies to the check too, so the type checker reads the same tsconfig.json as the bundler and the runtime. So does tsconfig in Bun.build.

Bun.build takes check: true. A type error fails the build like any other build error, and nothing is written. The check uses the conditions and loader of the build.

const result = await Bun.build({
  entrypoints: ["./src/index.ts"],
  outdir: "./out",
  check: true,
  throw: false,
});

for (const log of result.logs) {
  console.error(`${log.position?.file}:${log.position?.line}: ${log.message}`);
}
// /app/src/index.ts:3: TS2322: Type 'string' is not assignable to type 'number'.

Compiler options as flags

Any compiler option works as a flag, like in tsc. Flags override tsconfig.json.

bun check --noUncheckedIndexedAccess
bun check --strict false
bun check --target es2022

Output

In a terminal:

1 | import { greet } from "./user";
2 |
3 | const message = greet({ id: "1", name: "Ada" });
                            ^
error: TS2322: Type 'string' is not assignable to type 'number'.
    at src/index.ts:3:25

2 |   id: number;
      ^
note: The expected type comes from property 'id' which is declared here on type 'User'
   at src/user.ts:2:3

Found 1 error in 1 file, checked 2 files [14.00ms]

When there are more than 50 errors, identical errors are grouped and sorted by how often they happen. --all prints every one.

1 | export const v0: number = "0";
                 ^
error: TS2322: Type 'string' is not assignable to type 'number'.
    at m1.ts:1:14
    60 times in 2 files
      30  m1.ts:1
      30  m2.ts:1

When stdout isn't a terminal, you get the same format as tsc --pretty false, so problem matchers and scripts written for tsc keep working.

src/index.ts(3,25): error TS2322: Type 'string' is not assignable to type 'number'.

In GitHub Actions, errors are also printed as workflow commands so they show up as annotations on the diff, also when the step runs in a subdirectory.

::error file=src/index.ts,line=3,col=25,endLine=3,endColumn=27,title=TS2322::Type 'string' is not assignable to type 'number'.

With AGENT=1:

<error file="src/index.ts" line="3" column="25" code="TS2322">
Type 'string' is not assignable to type 'number'.
<source>
1 | import { greet } from "./user";
2 |
3 | const message = greet({ id: "1", name: "Ada" });
                            ^^
</source>
<related file="src/user.ts" line="2" column="3">The expected type comes from property 'id' which is declared here on type 'User'</related>
</error>

What you need installed

bun add -d @types/bun

That's for console, fetch, Bun and bun:test. bun init installs it for you.

You don't need the typescript package. TypeScript's lib.*.d.ts files (Array, Promise, the DOM and so on) are built into Bun. They're always the ones from TypeScript 7.0.2, no matter which version your project has installed. Your editor may still want the package.

$ bun -p process.versions.typescript
7.0.2

Flags

Flag What it does
-p, --project <path> A tsconfig.json, or the directory that holds one
--pretty / --no-pretty Source around each error, or one line per error
--all Show every error instead of grouping identical ones above 50
--threads <n> Number of threads. Default: one per core
--timing Print how long loading and checking took
--cwd <path> Set the working directory
--strict, --target, ... Any compiler option, as for tsc

Docs are in docs/runtime/check.mdx.

Also fixed

3 bugs that aren't in the type checker. We ran into them while testing it, and they're on main too.

  • Bun.build({ tsconfig: "./custom.json" }) ignored the option. The 3 tests for it passed anyway, because in each of them the tsconfig.json that Bun finds by itself says the same thing. Now that build reads the file in place of every tsconfig.json, like --tsconfig-override. For both, a directory means the tsconfig.json in it, like tsc -p. Other builds in the same process, and the program's own imports, aren't affected, even while it's running.
  • --tsconfig-override printed Internal error: directory mismatch for directory "/app/custom.json", fd 3. You don't need to do anything, but this indicates a bug. every time, for bun build and for bun run.
  • On Linux without pidfd_open, a child process that exits could interrupt a system call of the main thread with EINTR. Code that doesn't retry, like an addon or bun:ffi, saw a failed read(). It also made spawn.test.ts fail in about half of the ASAN builds of this branch.

Intentional differences from tsc

  • No emit. noEmit, declaration and incremental don't change anything.
  • bun check has no --watch. bun --watch --check <file> checks before every restart.
  • Language service plugins (plugins in compilerOptions) aren't loaded.
  • listFiles, listFilesOnly and traceResolution work. Other options that only print things, like explainFiles, are ignored.
  • Messages that tell you to run npm i --save-dev say bun add -d.
  • The lib.*.d.ts files are built in. A path in one of them is printed as bundled:///libs/lib.dom.d.ts.
  • A misspelled compiler option gets a suggestion: Unknown compiler option 'strct'. Did you mean 'strict'?
  • If an import fails because a package in package.json isn't installed, a note names the package.json and says how to run bun install for it.
  • If a syntax error or an invalid option stops the type check, like it does in tsc, a note says how many files weren't checked: Stopped before type checking 2 files. Fix the errors above to see the rest.
  • TS2309 (export = in a module that has other exports) is also reported when the other export is in a declare module block in another file. tsc 6.0.2 reports it too. tsc 7.0.2 only does when both are in one file.
  • acc = apply(run, ["x", acc]) in a loop, where the parameter is a tuple: tsc 7.0.2 reports TS2345 because the array literal loses its tuple type. tsc 6.0.2 doesn't, and bun check doesn't.
  • You get the same errors no matter how many threads it uses or what order the files are in. That isn't always true of tsc.

You can see it with 2 files:

// a_user.js
const p = new Packet()
p.a = 1
// z_packet.js
class Packet {
  constructor () {
    this.a = null
    this.c = null
  }
  own () { return this.c }
}

With allowJs, checkJs and strict:

TS7008 on a TS7008 on c
tsc 6.0.2 yes yes
tsgo 7.0.2 --singleThreaded or --checkers 1 yes yes
tsgo 7.0.2, default no yes
all of the above, with a_user.js renamed to zz_user.js no yes
bun check, either name yes yes

How did you verify your code works?

This is about 150,000 lines of new code, so there's a lot of room for mistakes. A type checker that's wrong once in a thousand files is worse than not having one, and "the tests pass" isn't a good enough reason to trust it. Most of the work here went into trying to prove it wrong.

Everything below compares bun check against the real tsc from TypeScript 7.0.2 on the same files. None of it compares against what we think the answer should be.

Real projects

Each of these repos is checked with tsc 7.0.2 and with bun check, once for every tsconfig.json in it. "Identical" means both report the same set of (file, line, column, error code).

Project tsconfig.json files Identical Errors in tsc Errors in bun check Messages worded differently
vercel/ai 52 52 9,742 9,742 0
anthropics/anthropic-sdk-typescript 16 16 596 596 0
apollographql/apollo-client 7 7 559 559 0
arktypeio/arktype 5 5 389 389 0
withastro/astro 12 12 1,996 1,996 0
axios/axios 1 1 105 105 0
vuejs/core 6 5 1,359 1,358 0
date-fns/date-fns 3 3 1 1 0
discordjs/discord.js 2 2 3 3 0
drizzle-team/drizzle-orm 3 3 10,786 10,786 0
Effect-TS/effect 10 10 2 2 0
elysiajs/elysia 4 4 53 53 0
excalidraw/excalidraw 7 7 11,048 11,048 0
fastify/fastify 1 1 0 0 0
TanStack/form 13 13 124 124 0
gcanti/fp-ts 2 2 3 3 0
sindresorhus/got 1 1 2 2 0
graphql/graphql-js 3 3 24 24 0
unjs/h3 1 1 0 0 0
honojs/hono 6 6 14,239 14,239 0
immerjs/immer 1 1 5 5 0
gcanti/io-ts 2 2 3 3 0
pmndrs/jotai 2 2 7 7 0
sindresorhus/ky 2 2 52 52 0
kysely-org/kysely 6 6 1,185 1,185 0
langchain-ai/langchainjs 41 41 5,273 5,273 0
lit/lit 34 34 2,588 2,588 0
mikro-orm/mikro-orm 17 17 87,981 87,981 0
mobxjs/mobx 3 3 186 186 0
mswjs/msw 10 10 5,878 5,878 0
nestjs/nest 30 30 27,345 27,345 0
vercel/next.js 356 356 3,690 3,690 0
unjs/nitro 5 5 742 742 0
nuxt/nuxt 3 3 11 11 0
openai/openai-node 4 4 6 6 0
microsoft/playwright 4 4 22 22 0
preactjs/preact 1 1 1 1 0
prisma/prisma 44 44 24,201 24,201 0
puppeteer/puppeteer 10 10 2,701 2,701 0
TanStack/query 15 15 686 686 0
react-hook-form/react-hook-form 2 2 3 3 0
remix-run/react-router 14 14 6,735 6,735 0
reduxjs/redux 1 1 0 0 0
reduxjs/redux-toolkit 8 8 142 142 0
remeda/remeda 7 7 13 13 0
rollup/rollup 9 9 107 107 0
TanStack/router 29 29 4,853 4,853 0
ReactiveX/rxjs 10 10 22,271 22,271 7
sequelize/sequelize 16 16 6,768 6,768 0
solidjs/solid 7 7 740 740 0
storybookjs/storybook 19 19 1,401 1,401 0
stripe/stripe-node 4 4 42 42 0
sveltejs/svelte 5 5 186 186 0
vercel/swr 4 4 314 314 0
TanStack/table 20 20 574 574 0
tldraw/tldraw 4 4 0 0 0
gvergnaud/ts-pattern 2 2 2 2 0
total-typescript/ts-reset 2 2 4 4 0
millsp/ts-toolbelt 1 1 3 3 0
sindresorhus/type-fest 2 2 0 0 0
typeorm/typeorm 4 4 959 959 0
microsoft/TypeScript 4 4 613 613 0
typescript-eslint/typescript-eslint 35 35 18,392 18,392 0
urql-graphql/urql 2 2 17 17 0
fabian-hiller/valibot 2 2 1 1 0
pmndrs/valtio 1 1 0 0 0
vitest-dev/vitest 18 18 2,111 2,111 0
microsoft/vscode, src/tsconfig.json 1 1 0 0 0
vscode's extensions, tests and base configs 105 105 5,685 5,685 0
statelyai/xstate 8 8 545 545 0
jquense/yup 1 1 1 1 0
colinhacks/zod 10 10 191 191 0
pmndrs/zustand 1 1 0 0 0
all 72 1,103 1,102 286,267 286,266 7

The error counts show how much there was to compare. They aren't what you'd get building these projects properly. Each row adds up all of its configs, each checked by itself, and most of the projects are installed but not built, so imports of their own dist folders don't resolve. A project with no errors only shows that bun check raises no false alarms. One with thousands shows that it finds the same problems in the same places. 82 of the configs have no errors in either.

VS Code's own build is src/tsconfig.json: 9,795 files, 0 errors in both. The other 105 configs in that repo are its extensions, its tests and base configs that only exist to be extended. The extensions' dependencies aren't installed here, which is where 5,272 of those errors come from.

There's no config that passes in one and fails in the other.

Not every one of those is a comparison of types. Of the 637 configs of the 69 repos, tsc reports type errors in 494 (276,703 errors) and nothing in 82. In the other 61 it stops at an error about the config itself, most often baseUrl, which TypeScript 7 removed, and checks no types at all. bun check stops there too, so those 61 only compare that error. For 7 repos that's every config we have: discord.js, fp-ts, io-ts, preact, ts-toolbelt, urql and yup.

The table counts an error as the same if the file, line, column and code are. Comparing the whole text of all 277,975 errors, with the lines under them, 9 are worded differently: the 7 in rxjs that are under Known problems, and 1 in sequelize that 2 configs report, where tsc names the type string | (object & { url?: string; }) and bun check names its alias.

For vscode, next.js and elysia that's every tsconfig.json in the repo. The other 69 repos have 1,311 between them, and we dropped the ones that give the same answer as another config in the same repo. Configs with references (138 of them) are compared against tsc -b, the rest against tsc -p.

That comparison is about which errors there are. For order and repeats we took every config with references, 317 in 21 of the repos, and compared the error lines one by one as they're printed. tsc -b repeats a line in 26 of them, 1,047 lines in all, because 2 projects include the same file. 316 are identical. The other one is the vercel-ai config below, and it's identical to the second build.

2 places where the comparison is lenient:

  • typescript-go's TS6059 and TS6307 (a file isn't under rootDir, or isn't listed in the project) change from run to run. We ran it 6 times per config. bun check is allowed to report what any of the 6 runs reported, and has to report what all 6 did.
  • One config (in vercel-ai) is compared against typescript-go's second -b build instead of its first. Its first build caches "this file doesn't exist" for outputs that it then writes, and reports 167 errors that go away if you run it again.

Elysia's Eden Treaty turns the type of a whole server into a typed client, which is about as hard on a type checker as real code gets. Besides the repo, we check apps with 17, 50 and 200 routes, and a program that uses edenFetch, WebSockets, macros, guards, file uploads, cookies, SSE and validators from zod, valibot and arktype, each with correct and incorrect calls. Every error and every message is identical. One of the apps is a test in this PR.

These are npm packages that publish their .ts sources and a tsconfig.json, checked where they sit in node_modules:

Package Version Files Errors in tsc Errors in bun check Messages worded differently
<%= dasherize(name) %> 0.0.0 6 1 1 0
@actions/github-script 7.0.1 7 0 0 0
@arrows/array 1.4.1 97 0 0 0
@arrows/composition 1.2.2 28 4 4 0
@arrows/dispatch 1.0.3 11 0 0 0
@arrows/error 1.0.2 3 2 2 0
@arrows/multimethod 1.4.1 12 4 4 0
atomically 1.7.0 11 26 26 0
@aws-crypto/sha1-browser 5.2.0 5 14 14 0
@aws-crypto/sha256-browser 5.2.0 5 14 14 0
@aws-crypto/sha256-js 5.2.0 5 15 15 0
@aws-crypto/util 5.2.0 5 15 15 0
@azure/arm-appservice 15.0.0 69 12 12 0
@azure/arm-resources 5.2.0 25 2 2 0
@bcoe/v8-coverage 0.2.3 8 6 6 0
benny 3.7.1 13 10 10 0
@braintree/sanitize-url 7.1.1 4 1 1 0
@braintree/sanitize-url 7.1.2 4 1 1 0
comment-parser 1.2.4 35 25 25 0
comment-parser 1.4.1 35 27 27 0
comment-parser 1.4.5 35 27 27 0
comment-parser 1.4.6 35 27 27 0
comment-parser 1.4.7 35 27 27 0
comment-parser 1.4.8 35 1 1 0
comment-parser 1.4.9 35 1 1 0
@compodoc/ngd-core 2.1.1 3 0 0 0
@compodoc/ngd-transformer 2.1.3 3 36 36 0
convex 1.28.2 174 135 135 0
convex-helpers 0.1.104 30 1 1 0
csp_evaluator 1.1.1 22 0 0 0
devcert 1.2.3 11 14 14 0
dexie-react-hooks 4.4.0 23 0 0 0
editions 6.22.0 4 6 6 0
@electric-sql/pglite-socket 0.0.6 8 2 2 0
@electric-sql/pglite-socket 0.1.3 9 2 2 0
@electric-sql/pglite-socket 0.2.7 10 3 3 0
@electric-sql/pglite-tools 0.2.7 7 2 2 0
@electric-sql/pglite-tools 0.3.3 7 2 2 0
@embroider/reverse-exports 0.2.0 3 86 86 0
example-typescript 1.0.0 7 13 13 0
expo-asset 11.1.5 25 80 80 0
expo-asset 12.0.13 25 74 74 0
expo-asset 57.0.18 26 94 94 0
expo-constants 17.1.6 6 69 69 0
expo-constants 18.0.13 6 67 67 0
expo-constants 57.0.19 6 87 87 0
expo-file-system 18.1.10 18 79 79 0
expo-file-system 19.0.22 22 52 52 0
expo-file-system 57.0.7 36 70 70 0
expo-font 13.3.1 19 18 18 0
expo-font 14.0.11 21 56 56 0
expo-font 57.0.4 24 2 2 0
expo-haptics 15.0.8 4 53 53 0
expo-image 3.0.11 32 94 94 0
expo-keep-awake 14.1.4 4 5 5 0
expo-keep-awake 15.0.8 4 52 52 0
expo-keep-awake 57.0.2 4 70 70 0
expo-linking 8.0.12 13 69 69 0
expo-modules-autolinking 2.1.10 22 3 3 0
expo-modules-autolinking 3.0.25 40 2 2 0
expo-modules-autolinking 57.0.13 46 1 1 0
expo-modules-core 2.3.13 41 25 25 0
expo-modules-core 3.0.30 44 57 57 0
expo-modules-core 57.0.18 46 75 75 0
expo-splash-screen 31.0.13 4 53 53 0
expo-sqlite 14.0.6 14 34 34 0
expo-status-bar 3.0.9 5 56 56 0
expo-status-bar 57.0.1 6 72 72 0
expo-symbols 1.0.8 5 55 55 0
expo-system-ui 6.0.9 8 52 52 0
expo-web-browser 15.0.11 7 52 52 0
@expo/devcert 1.2.0 11 25 25 0
@expo/devcert 1.2.1 11 84 84 0
@expo/dom-webview 57.0.1 8 76 76 0
@expo/log-box 57.0.4 42 74 74 0
feed 4.2.2 12 0 0 0
@glimmer/component 2.1.1 4 5 5 0
@huggingface/jinja 0.3.4 6 0 0 0
human-id 4.1.1 3 0 0 0
human-id 4.1.3 3 0 0 0
human-id 4.2.0 3 0 0 0
human-id 4.2.1 3 0 0 0
import-in-the-middle 1.14.0 3 2 2 0
import-in-the-middle 1.15.0 3 2 2 0
import-path-rewrite 0.0.1 4 5 5 0
junit-xml 1.2.0 5 1 1 0
khroma 2.1.0 50 148 148 0
@layerup/layerup-security 1.6.0 5 0 0 0
@lokalise/node-api 12.8.0 226 1 1 0
merge-anything 2.4.4 3 6 6 0
mimetext 3.0.28 8 7 7 0
@monaco-editor/react 4.7.0 3 4 4 0
mongodb 6.21.0 131 64 64 0
mongodb 7.5.0 131 1 1 0
mongodb 7.6.0 132 1 1 0
mongodb 7.7.0 132 62 62 0
moo-color 1.0.3 12 33 33 0
next-mdx-remote-client 1.1.2 15 1 1 0
npx-import 1.1.4 4 1 1 0
oblivious-set 2.0.0 3 14 14 0
onigasm 2.2.5 5 33 33 0
openapi3-ts 2.0.2 12 11 11 0
opencontrol 0.0.6 5 52 52 0
path-data-parser 0.1.0 4 0 0 0
pickleparser 0.2.1 19 278 278 0
pino 10.3.0 17 3 3 0
pino 10.3.1 17 3 3 0
pino 8.17.2 17 42 42 0
pino 9.14.0 17 42 42 0
piscina 3.1.0 28 34 34 0
piscina 4.8.0 46 46 46 0
pkg-pr-new 0.0.53 4 8 8 0
pkg-pr-new 0.0.71 4 44 44 0
pkg-pr-new 0.0.72 4 12 12 0
pkg-pr-new 0.0.75 4 12 12 0
pkg-pr-new 0.0.87 4 48 48 0
pkg-pr-new 0.0.88 4 48 48 0
@pnpm/config.env-replace 1.1.0 3 11 11 0
@pnpm/network.ca-file 1.0.2 3 11 11 0
protractor 7.0.0 7 1 1 0
react-static-example-typescript None 10 17 17 0
@redocly/openapi-core 1.34.20 278 49 49 0
resolve-package-path 1.2.7 4 116 116 0
resolve-package-path 2.0.0 4 116 116 0
resolve-package-path 3.1.0 4 104 104 0
rxjs 7.3.0 250 2 2 0
rxjs 7.8.1 251 1 1 0
rxjs 7.8.2 251 1 1 0
signal-polyfill 0.2.2 25 14 14 0
@stablelib/base64 1.0.1 3 15 15 0
@statelyai/inspect 0.4.0 11 22 22 0
@streamparser/json 0.0.21 29 1 1 0
@supabase/ssr 0.5.2 12 4 4 0
template 0.0.0 35 63 63 0
tunnel-rat 0.1.2 3 0 0 0
unfurl.js 6.4.0 14 16 16 0
uri-js 4.2.2 10 1 1 0
@vercel/agent-eval-playground 0.1.3 40 106 106 0
@verdaccio/core 8.0.0-next-8.1 19 3 3 0
@verdaccio/logger-prettify 8.0.0-next-8.0 9 15 15 0
@verdaccio/url 13.0.0-next-8.1 6 2 2 0
@verdaccio/utils 7.0.1-next-8.1 11 7 7 0
vite-plugin-externalize-deps 0.10.0 4 0 0 0
vscode-tas-client 0.3.3 7 0 0 0
@wdio/xvfb 9.27.2 5 16 16 0
webpod 0.0.2 4 8 8 0
with 7.0.2 3 10 10 0
workbox-core 7.3.0 30 34 34 0
workbox-core 7.4.1 30 34 34 0
@workflow/world 5.0.0-beta.27 29 4 4 0
yaml-ast-parser 0.0.43 38 167 167 0
all 151 4,096 4,496 4,496 0

We developed against most of the repos in the first table, so try it on your own project:

bunx -p typescript@7.0.2 tsc --noEmit --pretty false > tsc.txt
bun check --no-pretty > bun.txt
diff tsc.txt bun.txt

If there's a difference, please open an issue.

Compiler options the projects don't use

Projects only exercise the options they've chosen. So the 69 repos are checked again under 11 other sets of options.

Options Projects Identical Errors in tsc Errors in bun check
every strictness flag on 69 69 100,756 100,756
strict off 69 68 76,960 76,958
skipLibCheck off 69 69 90,609 90,609
checkJs 69 69 84,122 84,122
declaration 69 69 80,495 80,495
isolatedModules with verbatimModuleSyntax 69 69 80,622 80,622
isolatedDeclarations 62 62 34,982 34,982
erasableSyntaxOnly with legacy decorators 69 69 71,577 71,577
nodenext 69 69 85,238 85,238
"types": [] 69 69 105,818 105,818
"lib": ["es5"] 69 69 83,872 83,872

typescript-go crashes or times out on 7 repos with isolatedDeclarations, so those aren't compared. Of the 896,192 errors both report across all 11 sets, 21 are worded differently.

TypeScript's own tests

typescript-go runs TypeScript's compiler and conformance tests on itself (12,762 files). Some tests ask for several sets of compiler options, which makes 13,101 runs. For each one it commits what it expects, as files. bun check has to produce the same files, byte for byte.

Baseline What it holds Tests Identical
.errors.txt every error, with the source around it 13,101 13,101
.types the printed type of every expression and name 12,463 12,463
.symbols the symbol of every name, with where it is declared 12,463 12,463
.js the declaration files that are emitted, and the errors in them 13,032 13,032
.trace.json the log of how every import was resolved, with traceResolution 151 151

Not every test has every kind of baseline. The .errors.txt baselines contain 966 different error codes.

A .js baseline also has the JavaScript that tsc emits. That part isn't compared, because Bun has its own transpiler. The same goes for the .js.map and .sourcemap.txt baselines. bun check doesn't write declaration files for you either. It generates them in memory for projects that other projects reference, and that's the code these baselines test.

typescript-go's test runner skips 45 tests, and 8 more when it compares what's emitted. This one skips the same ones.

The tests and the baselines are vendored in this PR as one file (test/cli/check/typescript-go/bundle.zst, 8 MB, written by sync.ts from a typescript-go tag). All 5 kinds run in CI:

bun bd test test/cli/check/conformance

Generated programs

TypeScript's tests only have a few forms of some things. Take this:

let s: S | null = mk();
while (s) {
  const cur = { v: s };
  s = cur.v;
}

tsc reports TS7022 on cur. Its type needs the type of s, which needs what the loop assigns at the bottom, which needs cur. There are thousands of ways to write that loop, and a type checker can pass every one of TypeScript's tests and still say nothing about most of them.

So there are generators. Each one writes a cross product with one function per line, runs tsc and bun check on it, and compares. There's no expected output stored anywhere. tsc is the oracle.

These are in test/cli/check/differential.test.ts, and all of them pass:

What it generates Functions What has to match
narrowing: the reference, the guard, the control flow around it, what happens before the use 46,080 every error and its message
variables without a type annotation 29,484 every error and its message
calls with callbacks: the callee, the form of the callback, the context of the call 9,984 every error and its message
circular references, directly and through a second declaration 9,196 every error and its message
a variable in a loop that's assigned back, in 12 kinds of loop 20,304 which loops have an error
index signatures: 14 kinds of key like string & {}, what they're indexed by, how a name is written in a destructuring or an object literal 4,522 every line tsc prints
index signatures: the key of the source and the key of the target, in assignments, inference and redeclarations 1,248 every line tsc prints
a circular reference next to a call with overloads and a callback 3,888 every line tsc prints
an interface that extends a mapped type of itself, in a .d.ts that skipLibCheck hides: 13 forms of base type, 9 kinds of declaration, 36 uses 4,212 every line tsc prints
the same in a file that's checked, with the declaration before its use 4,212 every line tsc prints
a conditional type inside another one that checks the same type, with infer 480 every line tsc prints
a boolean: 13 ways to get one, 16 types it isn't assignable to, 8 ways to assign it 1,664 every line tsc prints
a package with export =: what it exports, what declare module adds to it, how it's imported, 3 messages 144 every line tsc prints
a parameter property that is a pattern: 5 modifiers, 5 patterns, 3 sets of options 75 every line tsc prints
a function from an index signature that is tested and not called: 11 receivers, 11 tests 119 every line tsc prints
a generator with literals where any type is expected: 14 contexts, 10 generators 140 every line tsc prints
a conditional or a mapped type over this: 9 types, 28 uses 252 every line tsc prints
a type that is generic as an object, as an index, as both or as neither: 13 types, 12 places 156 every line tsc prints
what a project with references imports: 5 imports, 5 sets of options, 3 sets of references 75 every line tsc -b prints, in order

It also generates small projects, for file names that differ only in case, like import "./Button" for button.ts. macOS and Windows take those for the same file and Linux doesn't, so it runs on both kinds of file system. Every case runs with forceConsistentCasingInFileNames on, off and unset.

What it generates Cases What has to match
how a file is referred to (8 forms of import and reference, packages, files), in which spellings, in which order, from where 150 every line tsc prints
the same where case matters, plus 2 real files like Button.ts and button.ts 167 every line tsc prints
a project reference that's spelled differently from the directory, or in 2 spellings 27 every line tsc -b prints
tsconfig.json with a value that isn't JSON, like "strict": tru: 28 kinds of value in 30 places 840 every line tsc prints
tsconfig.json whose root is a list, like [{ "compilerOptions": {} }] 9 every line tsc prints
3 projects with references and files that all of them include: the order at the top, who references whom, a project that doesn't exist, a type library that's missing 189 every line tsc -b prints, in order
import() and require() with import or require somewhere in their text, which changes what tsc says about why a file is in the program 44 every line tsc prints

The index signature and overload programs end with a line that has a type error, and tsc has to report it. After a syntax error neither of them checks anything, and then they'd agree about everything. CI runs 70 of the 840 tsconfig.json files, because each one takes 2 processes. All 840 were compared on a laptop.

It runs in CI against TypeScript 7.0.2, which test/package.json installs next to 6 as typescript7. You can also point it at another tsc:

TSC=/path/to/typescript-7/tsc bun bd test test/cli/check/differential.test.ts

These run locally against tsc and aren't in the repo yet:

What it generates Programs Different from tsc
classes: access, overriding, initialization, heritage, super, references to itself, 2 members with one name 290,000 0
overloads, inference, variance (21 groups) 1,300,000 0
JSX: 48 tags, 48 lists of attributes, 23 forms of children 63,770 0
where a long type is cut off in a message 1,060,000 0
2 declarations of one variable or property 123,891 0
module augmentation through re-exports and aliases 210 1
JSDoc tags on every kind of host, in checked JavaScript 68,334 0
class expressions that refer to what holds them 40,128 21
type annotations inside an expression that refer to what's being declared 50,688 95
symbols that tsc creates lazily 15,444 25
immediately invoked functions 19,200 21
type aliases that refer to themselves without end, through 36 kinds of type 517 28

The 1 in module augmentation is the wording of a message. In 28 of the messages that are cut off, tsc doesn't agree with itself from run to run, and those aren't counted.

The 4 rows before the last are all code that refers to itself while it's being declared, like class A { x = { a: null! as { p: A["x"] } } }. In 3 of those 162, tsc reports an error and bun check reports nothing for that declaration.

In the 28 of the last row, both report errors, but not the same list: TS2589 is at another position or in another order, or the TS2538 next to it is missing. tsc crashes with a stack overflow on 23 more of those programs, which aren't counted. bun check reports an error in all 23.

Is it overfit to the tests?

  • We searched the type checker's code for the names of TypeScript's tests. Of the 12,271 names that are 12 characters or longer, 19 appear in code. All 19 are also the name of a compiler option or a builtin (esModuleInterop, noUncheckedIndexedAccess, defineProperty and so on).
  • It's a port. 1,791 of the 2,770 functions in typescript-go's internal/checker are named in comments, so you can read the two side by side.
  • A bug found in a real project or by a generator gets reduced to a few lines and added to test/cli/check/check.test.ts, with the output of tsc as the expected output. A test is only added if it fails without the fix. There are 431 tests in that file.

Read side by side with typescript-go

Passing tests doesn't show that a port behaves like the original. TypeScript's tests guard tsc against its own regressions, and tsc never had this port's bugs. So every function of typescript-go's checker, binder, module resolver, config parser, program and build mode was read next to its counterpart here, 43 slices of about 2,000 lines each. Wherever the two could behave differently, the reader had to write a small program that shows it and run both tools on it. A second reader then ran each program again and tried to refute it. About 190 were refuted.

That left 1,794 programs on which bun check and tsc printed something different. Each was marked by how likely it is to come up:

How likely Programs Same as tsc now
ordinary code, or the mistakes people make while editing it 27 26
valid but unusual: advanced types, unusual options 420 389
next to another error that already fails the build, without the default library, or code nobody writes on purpose 1,347 237
all 1,794 652

The marks are a reader's judgement, not a measurement.

By what a user would see:

Difference Programs Same as tsc now
an error is missing 695 252
an error that tsc doesn't report 563 190
other words 448 178
other position 63 26
other order 24 6
crash 1 0

The crash no longer crashes. It still prints one line more than tsc.

641 of the 652 are in test/cli/check/differential-cases.json, and differential.test.ts runs tsc and bun check on each. The other 11 need React's types, or print where the lib files are.

Threads

It uses all of your cores, so the answer could depend on which thread gets somewhere first.

One of the tests generates 60 modules that all enter the same cycles (variance of type parameters, recursive type aliases, functions and constants without annotations) and checks them on 1, 2, 3, 8 and 16 threads. The output has to be byte for byte the same. typescript-go reports 518, 519, 521 and 525 errors for that program with 1, 2, 4 and 8 checkers.

Broken code

You run a type checker on code you're in the middle of writing. The worst thing it can do there is give up on a file and say everything is fine.

One of the tests takes 268 valid files from TypeScript's tests and damages each in 7 ways (truncate, delete a token, insert a stray token, swap 2 tokens, replace a token, cut at a random byte, delete a line), then appends a type error. With 17 more written by hand, that's 1,893 files. Every one of them has to report something.

Breaking real code

Most of the code in the projects above compiles. To see what happens to real code with errors in it, a script takes packages/effect from Effect (496 files, and about as hard on a type checker as TypeScript gets), breaks 4 files at a time, runs tsc and bun check, and compares every error with its whole message. There are 32 ways to break a file: swap 2 arguments, drop a type argument, turn never into unknown, delete an overload, reverse a conditional type, remove readonly, and so on.

Mutations Errors in tsc Different error codes Missing in bun check Only in bun check Worded differently
913 2,925 67 0 0 8

Module resolution

With traceResolution, tsc logs every step of resolving every import: each file it looks for, each package.json it reads, each condition in exports it tries. bun check prints the same log. For 69 of the projects above that's 8,463,167 lines, and all 69 logs are identical.

typescript-go resolves on several threads, so which lookup of a package.json says "cached" changes between runs. Both logs go through the normalization that typescript-go's own test runner uses for that.

Overflow checks and assertions

A release build doesn't check for integer overflow, so an index that wraps around can go unnoticed as long as the output looks right. All of TypeScript's tests and all 637 tsconfig.json files from the survey also run on a build with overflow checks and debug assertions turned on. Nothing panics and the output is the same.

Big inputs

16 tests generate programs that grow in one direction: 400 if statements in a finally block, 400 nested loops, a chain of 400 method calls, a union of 100 object types narrowed one member at a time, 400 overloads, and so on. An exponential algorithm doesn't finish at those sizes.

4 more are long instead of deep: 40,000 top-level lines of export const v = o.a.b.c + k.y.y.x, of template expressions, and of calls with callbacks. 16,000 lines of the first take 0.15 seconds. tsc 7.0.2 takes 53.

Everything else

Adding a type checker shouldn't make bun run or bun build slower when you aren't type checking. Parsing 7,012 TypeScript files (37 MB) takes 1.003x the instructions it does on main, and typescript.js (9 MB) takes 0.995x.

Performance

tsc 7.0.2 with default settings against bun check on 16 threads. 16-core Apple silicon, 5 rounds, alternating between the two. Wall clock time is the fastest round, CPU time and memory are medians. Both report exactly the same errors in every row.

Wall clock time:

Project Files tsc 7.0.2 bun check Faster
vscode src 9,795 6.14 s 1.28 s 4.8x
mikro-orm 2,883 6.35 s 1.21 s 5.3x
next.js packages/next 2,881 1.86 s 0.28 s 6.5x
next.js, root 3,547 1.31 s 0.40 s 3.2x
storybook scripts 1,039 0.97 s 0.24 s 4.0x
nuxt 839 0.78 s 0.27 s 2.9x
playwright 706 0.66 s 0.16 s 4.2x
lit packages/react 6 2.14 s 0.46 s 4.6x

CPU time:

Project Files tsc 7.0.2 bun check Less
vscode src 9,795 31.1 s 13.5 s 2.3x
mikro-orm 2,883 30.3 s 10.4 s 2.9x
next.js packages/next 2,881 6.9 s 2.4 s 2.8x
next.js, root 3,547 4.9 s 3.7 s 1.3x
storybook scripts 1,039 4.9 s 1.7 s 2.8x
nuxt 839 3.9 s 1.6 s 2.5x
playwright 706 3.6 s 1.2 s 3.1x
lit packages/react 6 2.9 s 0.5 s 5.7x

Peak memory:

Project Files tsc 7.0.2 bun check Less
vscode src 9,795 7.93 GB 2.08 GB 3.8x
mikro-orm 2,883 6.31 GB 1.32 GB 4.8x
next.js packages/next 2,881 1.78 GB 0.69 GB 2.6x
next.js, root 3,547 1.32 GB 0.52 GB 2.6x
storybook scripts 1,039 1.33 GB 0.54 GB 2.5x
nuxt 839 1.19 GB 0.50 GB 2.4x
playwright 706 1.06 GB 0.43 GB 2.4x
lit packages/react 6 0.63 GB 0.23 GB 2.7x

The machine was busy with other work during these runs (load average 7 to 14), so both would be faster in wall clock time on an idle one. This was measured with a release build of the type checker on its own, not a release build of Bun. The tsc 6.0.2 number at the top is the better of 2 runs on Node 25.6, on the same machine under the same load.

Long-running processes

Bun.build({ check: true }) can run many times in one process. A test runs repeated builds and checks that memory doesn't grow.

A first run

bun init, then bun check --skipLibCheck false, which also checks every declaration file in @types/bun, @types/node and React against the built-in lib.*.d.ts files:

Template Files checked Errors
bun init -y 244 0
bun init --react 254 0
bun init --react=tailwind 256 0
bun init --react=shadcn 279 0

Projects that have an older typescript installed. bun check reports the same errors as it does with the files of the TypeScript 7.0.2 package:

Project Installed typescript Errors
dayjs 2.9.2 0
immer 5.0.2 5
svelte 5.5.4 62
zod 5.5.4 31
drizzle-orm 5.6.3 10,845
solid 5.7.3 352
vercel/ai 5.8.3 1,561
lit 5.9.2 1,891
apollo-client 5.9.3 461

A test compares all 108 built-in files with the typescript package, byte for byte.

Also compared with tsc 7.0.2, with the same output: 150 valid, outdated, misspelled, empty and wrongly typed values in tsconfig.json. Windows line endings, a byte order mark, UTF-16 and emoji in source files. Paths with spaces, Cyrillic, Japanese and accents. A tsconfig.json that extends 1 package, 2 packages or a missing one. Both kinds of decorators. The config files that create vite writes. A workspace whose packages import each other's sources.

bun check | head -1 with 24,000 errors exits normally. Ctrl-C in a terminal ends it in 35 ms with exit code 130.

What hasn't been tested

  • The comparisons against tsc on real projects only ran on macOS arm64. CI builds this for Windows x64 and arm64, macOS, Linux glibc and musl, Android and FreeBSD, and runs test/cli/check wherever it runs Bun's tests.
  • ThreadSanitizer has 0 reports in 111 runs, but that was an earlier commit of this branch.
  • Most of the unsafe code doesn't run under Miri.
  • Only TypeScript 7.0.2. We haven't tried syncing to a newer typescript-go.

Known problems

Where it differs from tsc 7.0.2:

  • typescript-go's answer for a file can depend on which other files are in the program, in what order, and on how many checkers it uses. bun check gives the same answer at any thread count, and everything here is compared against typescript-go with 1 checker. It shows in 1 of the 1,103 configs above: in vuejs/core's packages-private/tsconfig.json, 1 of typescript-go's 940 errors is missing, a TS2345. Reduced to 3 files, typescript-go reports that error with "files": ["global.ts", "model.ts"] and doesn't with ["model.ts", "global.ts"] or ["model.ts"]. Neither file imports the other.
  • In rxjs, 7 of 22,030 messages explain the error along a shorter path: The types returned by '[bufferTime](...)[groupBy]' are incompatible where typescript-go has '[bufferTime](...)[bufferTime](...)[groupBy]'. The position, the code and the first line of the message are the same. It has the same cause: the order the files are checked in.
  • nuxt with strict off: 7 of typescript-go's 74 errors are missing, and there are 5 that it doesn't report.
  • Single-quoted strings in package.json are accepted. typescript-go rejects them.
  • interface Foo extends Partial<Foo>, or any other mapped type of Foo, is an error in both (TS2310). What tsc says after it depends on what's asked for first, Foo or Partial<Foo>. Within a file bun check says the same. Across files it can differ, because tsc goes by the order of the whole program. With skipLibCheck off, an interface of the library that's extended like that can have members in bun check that it doesn't have in tsc.
  • More than 332 variables without annotations in one loop, each initialized with the one before it: bun check says it couldn't finish the file and exits with code 1.
  • 1,142 small programs are known on which the output differs, found by reading the code next to typescript-go's (see "Read side by side with typescript-go"). 1 is in ordinary code: with type A = string | boolean, the type of c ? [1] : x is printed as number[] | A where tsc prints string | boolean | number[]. 31 are in unusual code, like a tuple whose labels are spelled the same as another's, or a parameter typed only by its pattern { a = 1 }. The other 1,110 are next to another error, without the default library, or in code nobody writes on purpose.
  • The order of the members of a union of anonymous object types in a message can be different.
  • A type alias that refers to itself without end gets TS2589 from both, but it can be at another position, and a TS2538 next to it can be missing. That's 28 of 517 generated programs.
  • The lines under an error that explain it can differ: 8 of 2,925 errors after breaking Effect's source.
  • for (!function () { n in e; }(); ;) break; is a syntax error in typescript-go 7.0.2. It isn't in TypeScript 6 or here. Minified jQuery 3 has that code.

This adds 6.08 to 7.29 MB to the binary, depending on the platform.

None of this would exist without typescript-go. It's the code this is ported from, the baselines that say what the type of every expression is, and a binary to compare against on any project.

@robobun

robobun commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 5:47 AM PT - Oct 6th, 2026

❌ @Jarred-Sumner, your commit 93975d8 has 3 failures in Build #123573 (All Failures):

  • test/js/sql/sql-close-pending-connection.test.ts - code 1 on 🍎 27 aarch64
  • test/js/node/net/node-net.test.ts - code 1 on 🍎 27 aarch64
  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #123538
    sizeΔ
    ❌ bun-darwin-aarch6466.95 MB60.94 MB+6.01 MB
    ❌ bun-darwin-x6473.78 MB66.84 MB+6.94 MB
    ❌ bun-linux-aarch6483.68 MB77.05 MB+6.63 MB
    ❌ bun-linux-x6484.18 MB77.50 MB+6.68 MB
    ❌ bun-linux-aarch64-musl76.63 MB69.88 MB+6.75 MB
    ❌ bun-linux-x64-musl78.20 MB71.33 MB+6.87 MB
    ❌ bun-linux-aarch64-android89.54 MB83.41 MB+6.13 MB
    ❌ bun-linux-x64-android93.62 MB86.65 MB+6.96 MB
    ❌ bun-freebsd-x6492.00 MB85.36 MB+6.64 MB
    ❌ bun-freebsd-aarch6492.94 MB86.37 MB+6.57 MB
    ❌ bun-windows-x6491.20 MB84.20 MB+7.00 MB
    ❌ bun-windows-aarch6478.74 MB72.96 MB+5.78 MB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 44361

That installs a local version of the PR into your bun-44361 executable, so you can run:

bun-44361 --bun

…s that never wait to read

Loading:
- A few threads do nothing but read, one file after the other. The rest parse what has been read and never wait for a turn to read.
- A file is read and parsed as soon as another is seen to refer to it, not when all files found so far are done. Which number a file gets, and which of two that are the same package is taken, is settled afterwards in the order it always was.
- Which files of a directory match the configuration is found out while directories are read, many at a time.
- 6 threads read at a time on macOS. With 4 to 10 the best case is the same; 5 or 6 do best when the system is slow to open files.
- The spelling of a private name goes by the path of its file, not by the number.
- The names a thread has come upon lately are found without going to what all threads share.
- Whether something is made of an expression (a cast, parentheses) is asked of a bit for the place it starts at before a table is looked into.

An entry point with the 16,500 files it imports loads in 0.53 s, from 0.75 s.

Also:
- `bun check` does not give memory back piece by piece before the process ends.
- What the binder worked out to spare going back through the flow of control is gone. It cost as much as it saved: 600 lines.
- The command line tool for development can be linked with mimalloc, and says how many instructions it took.
…o is tried first

- The type of a string literal is found by what the literal says, in an array: no hashing and no lock. There is one for nearly every string in a program.
- Tables many threads add to are in 256 parts, from 64. The place of an entry goes by what is kept of its hash, so a part grows without looking at what it is a table of, which it did while holding its lock.
- To tell whether a call of an overloaded function is in error, all that is asked is whether any overload applies. The one the call was resolved to is tried first, which spares inferring type arguments for those before it: 4% fewer instructions on a big project.
…second time

Choosing among overloads tries each candidate, which for a generic one means inferring its type arguments. The one that was chosen was then inferred for once more. If none of the arguments waits for the others there is nothing more to find out, and what the trial came to is taken. 13% fewer instructions on code that is mostly calls of overloaded generic functions, as tests are.
For a person at a terminal:
- What is underlined goes by how wide characters are shown: two columns for most of what is written in East Asia and for emoji.
- Of a line that is longer than the terminal is wide, the part around the error is shown.
- A message that does not fit goes on in the next line, indented, and is broken between words.
- Types and names quoted in a message have the colors they have in source.
- The reasons under an error are drawn as a tree.
- The summary lists the twelve files with the most errors, and says how many there are in the rest.

Also what is wrong with `compilerOptions` as written: an option there is no such thing as (with the one that was probably meant), a value of the wrong kind, and where in the file. What older versions of TypeScript took is let through. Not asked for by anything yet.
The biggest files are checked first. Among the rest, how long a file takes has little to do with how long it is, and in order of size the few that ask a lot of their types all came at the very end, where one thread worked and the others waited.

- Files that are not big are checked in no particular order, the same each time.
- When one of them takes long, what else is in its directory goes first: files that use the same types tend to be next to each other.
A big project takes seconds, in which nothing was shown. At a terminal, once 300 ms have gone by, a line on stderr says that the program is being loaded, then how many files have been checked and how many errors have been found. The bar goes by bytes of source, which the time goes by more than by files: the biggest files are checked first. The line is gone before the errors are shown. Nothing of it is shown to an agent or where stderr is no terminal.
… contextual types

Stores and tables:
- The element with a number is reached in four instructions.
- `mapper`, `intern` and `intern_sig` hash once and look at nothing but the thread's own table on a hit. Pairs that are in order are not sorted, and nothing is boxed before it is known to be new.
- What a table keeps for the thread is reached without a dynamic cast, and with one look at what is the thread's own instead of two.
- A short name is compared as four numbers.

Calls:
- The pass that reports on calls asks for the type of each argument once.
- Whether the number of arguments is right is told without looking at types unless something is left out or spread.
- The order overloads are tried in is kept.
- For something with one signature its parameters and type parameters are asked for once.

Contextual types:
- What a union comes to for an object literal is worked out once for the literal, not for each member.
- That there is nothing to prepare above an expression is remembered for what is around it.
- What a nested literal or a function in a literal is expected to be is looked up once.

No answer changes.
Relations:
- What is known on the way down a comparison (that no simple rule applies, the key, that it is not in the cache, what the two types are, their members) is passed on instead of being found out again at each level.
- `T | undefined` of an optional property, plain references to global types and what a mapped type's target has a symbol of are kept.
- The relater keeps the recursion identity of each type on its stacks.
- Object against object, and a member of a union against the union, are answered before anything is looked up.

Declarations:
- What a type parameter extends and its default are table loads, kept only if what they were read from is kept.
- Declared type parameters, identity mappers and global types are found in arrays, not by hashing.
- A flag says whether a type can hold an object literal. What awaiting a type gives is kept.

Shapes:
- A checker has its last answers at hand for members, signatures, parameters and type parameters of signatures.
- Whether a type may be reduced is a flag. Names in a shape are found through a plain table of positions.
- A property with one declaration keeps it in place, and a shape kept for all threads gives back the room it does not use.

No answer changes.
… and inference

- `instantiate` answers the easy cases with one look at the type, and has a small table of its own in front of the shared one.
- What pairs of types come to as a union is remembered where nothing but the members is looked at. `filter` and `map_type` allocate nothing for a union that stays as it is.
- What the parameter of a mapped type extends is kept. `tuple[number]` reads the index signature of the array the tuple is based on.
- A question is one frame on one stack, not an entry in each of five. `enter` is inlined without its rare branches. The aids for debugging test a flag and nothing else.
- The types of the expressions of the file at hand are read from the checker. `null`, `true`, `false`, numbers and strings go without a frame.
- The holes of an overloaded call are made when they are first read. A candidate that is not generic is tried as it is declared. The only signature of a type is instantiated once for the type. `returnMapper` is read off the inference just made.

No answer changes.
…and smaller trees

Passes:
- Arrays and tuples are known to be iterable at once. Passes start from the nodes they are about. The way out of an expression is not gone further than it can lead.

Lowering:
- The names a file has said before are found without hashing. `marks` is only searched where something is noted, and no list is allocated per node.

Loading:
- Which files nothing refers to is guessed from what they export, not from the names of directories: 7,000 files fewer are parsed a second time on a project of 45,000.
- A file is read with three system calls, not five, and its directory is only opened for the second file in it. A directory is listed with three.
- Paths are put together without formatting. What a specifier means in a directory is found out once. The labels of the binder are found by number.

Memory:
- Lists that grew give back what they do not use, which `shrink_to_fit` does not do under mimalloc unless a list is less than half full.
- The one declaration of a symbol is kept in the symbol. The lists most files have nothing in take one word. Places in the flow of control that nothing comes after are not kept.

No answer changes.
…ritten

An option that does not exist, a value of the wrong type and a value that is not among those an option takes were silently passed over. They are errors now (TS5023, TS5025 with a suggestion, TS5024, TS6046), shown at their place in tsconfig.json like any other error.
- A walk passes by a test that is about something else. What the operands of a test start with is worked out when a walk first gets to the test, and kept by flow node.
- What a discriminant or a comparison narrows a type to is kept by the types.
- Whether a property tells the members of a union apart is kept for all files.
- What a walk starts from is found out where a walk gets there, not before it sets out.
- Calls without effects are marked by flow node. A join of one type that is no union is that type.

No answer changes.
A union written out is the same type as an alias that stands for it, and goes by the name of the alias in messages. Which aliases counted was those that had been resolved by the time the message was put into words, which depends on what other threads have got to: the same input could print `string | Block[]` in one run and `Input` in the next.

The table of names is now filled in at once, in the order of the files, when a message first needs it. Threads that need it help find out what the aliases stand for instead of waiting. The output of a project of 45,000 files is the same byte for byte over repeated runs and with 1, 4 and 16 threads.
The message had a hole where the file goes. It also comes with TypeScript's advice now: install `@types/x`, declare the module, or amend DefinitelyTyped, by what the program has of the package.

Where TypeScript's messages say `npm i --save-dev`, `bun check` says `bun add -d`.
Such a file is parsed when the program is loaded, to find out what it imports, and again by the thread that checks it. It was also read twice. What it reads is now kept in between, and let go of when it has been checked.

Opening and reading files does not scale on macOS: 45,000 files take 0.5 s with 6 threads and 0.9 s with 16, which spend 10 s in the kernel between them. All threads were reading while they checked. 8% less time on a project of that size, for 180 MB more at the peak.
A class without a constructor is made the ways what it extends is, and each of those signatures is declared where the one it clones is. To find that place the base types of the class were asked for first, as a guard against a class that extends itself. TypeScript never asks for them there, and asking can come back to itself: a false TS2310 in `mixinWithBaseDependingOnSelfNoCrash1`. A class that extends itself has no base signatures to go on with, which ends the search by itself.
`')' expected.` came out as `'{0}' expected.`: the parser knew which token it missed and did not pass it on. An error of the parser can now name something that cannot be told from where it is. That fills TS1005 (650 places in TypeScript's tests), TS17002 and TS1209. TS6142 and TS2665 say which file a module resolved to, TS2615 which property of which mapped type.

Of 34,469 messages in TypeScript's tests, 99.2% now read the same in the first line (97.4% before) and 98.9% with every reason below it (97.2%). None has a placeholder left.

Also: a `this` parameter in a function type written in a JSDoc comment has the type that follows it. It was taken for the type of a `@this` tag, which does not count, and reported as implicitly `any` (TS7006).
… the driver of `bun check`

The development tool has a new command, `baselines`. It splits each compiler and conformance test into its files, puts them in a file system that is only in memory, checks them through the same function `bun check` calls, writes what comes out in the format of typescript-go's `.errors.txt` baselines, and compares. It needs no copy of the tests laid out on disk and no TypeScript to run, covers what a directory cannot express (drive letters, `..` above the root, odd encodings), and tests what ships: configuration errors, sorting, the words and how far each error reaches.

For that the driver can check a project through any host (`check_project`), and a configuration file can be loaded with options said after it (`load_overriding`), which a command line will need too.

Found by it so far:
- `node_modules`, `package.json` and `@types` in the root directory were looked for under `//`.
- Errors that are the same were not reported once (`SortAndDeduplicateDiagnostics`).
- Two errors with the same code that start at the same place and do not reach as far are two errors: `(a, b, c)` has one about `a` and one about `a, b`.
- Of an empty array pattern no element is asked for, so that it cannot be iterated is only said of the declaration.
Options that do not go together (`checkJs` without `allowJs`, `module` against `moduleResolution`, a `paths` entry that is not relative and thirty more) were reported by a code alone: the message had holes where the names go, and no place. `verifyCompilerOptions` is now ported with what each message names, what is said below it, and where it points: the name or the value of the option in tsconfig.json, an entry of `paths`, or the word `compilerOptions` if the option comes from elsewhere. The same for files that cannot be part of the program and for type libraries that are not found, which also say why they were asked for.

- `bun check` is `tsc --noEmit`, whatever the project says: nothing is written, so what is only wrong with where output would go is not looked into.
- What is wrong and is in no file (`Cannot find global type 'Array'.`) is reported.
- An error can come with related information (`'x' is declared here.`). Nothing fills it in yet.
…ently, and stop where tsc stops

- How much stack is in use was taken from the addresses of two locals. Under AddressSanitizer locals whose address is taken are not on the stack, so every question looked like it had run out of room and was answered "unknown": a debug build found 1 of 17 errors in a small project and said nothing about the rest. It now reads the stack pointer, as `StackCheck` does, and the limit is what the thread really has left.
- A file in which something went unanswered for want of stack is reported as such, and the exit code says so.
- `--timing` shows the most stack any file took.
- As `tsc` does: if something does not parse, that is all that is said. If the options do not go together, or a built-in type is missing, that is. Only then come the errors about types. With `baseUrl`, which TypeScript 7 removed, a project got thousands of errors about imports instead of the one that matters.
- An error can be of no length, as those TypeScript reports on missing nodes are.
…o it

Two classes of the same name in two files, or a class and a variable, were made one symbol with the declarations of both: twice the constructors, the members of either. It shows wherever two versions of a package that declares ambient modules are in one program, as with two versions of @types/node in a monorepo: `new ServerResponse({})` had "No overload matches this call" for what is a plain mismatch, and properties only one version has were there.

As `mergeSymbol` has it, the first keeps the name and stays what it was. What is refused stays what its own declarations are about. The names in its file that the binder had found it for get a symbol that stands in for the first.

In a project of 2,900 files that has 7,579 errors, 85 fewer are false and 156 fewer are missed.
…o says them

- A package that imports itself by name while `outDir`, `declarationDir` or `rootDir` map output back to input finds the input file. TS2209 and TS2210 where the root is ambiguous.
- Names that collide with what is emitted (TS2441, TS2529, TS1216), unless nothing is emitted.
- With `importHelpers`: TS2343, TS2354, TS2807 wherever syntax needs a helper that `tslib` does not have, and TS2818.
- TS1450, TS18057, "There are types at .." under TS7016, the file names in TS6263 and TS2306.
- Related information: where a variable or a member was declared before, which export default is the first, the class that could be declared, the container that hides an outer `this`, the spread that overwrites a property, where `type` is said on an import or export, the function to mark `async`, a missing `await`.
… are about is declared

- What the parser and the scanner object to ends where they said it does: the range they log is kept. Errors on a bare position or a missing node have no length.
- Grammar errors end with the node they are reported on: a list in angle brackets, a parameter, an index signature, a computed name, a decorator's `@`.
- `The parser expected to find a '}' to match the '{' token here.`
- Related information from the relation itself: `'x' is declared here.` under a missing property, `This type parameter might need an `extends` constraint.`
- Several errors with one code at one place are kept where TypeScript keeps them (TS2411, TS2420, TS2430, TS2416, TS2859).
- `noErrorTruncation`: without it long types are cut short, as tsc does.
- Syntax errors in JSON modules. A file that is no text (TS1490).
…ript-go says them

- `'x' is declared here.` under a name used before its declaration and under "Did you mean".
- `The expected type comes from property 'x' which is declared here on type 'T'`, from an index signature, from the return type of a signature.
- Where output goes: the common source directory, TS5009, TS5011, TS6059, and TS5055 / TS5056 under `outDir` and `declarationDir`.
- TS2318 for the global types that are only needed once something uses them, TS2468.
- `noCheck`, `preserveConstEnums`, `deduplicatePackages: false`.
- TS1005 and TS5024 in a configuration file.
- The test harness reads a configuration as TypeScript 7 does, takes `@pretty` and `@captureSuggestions`, and knows what emit adds and takes away.
- Many smaller things in how types are written out.
- `An argument for 'x' was not provided.`, `The last overload is declared here.`, `The call would have succeeded against this implementation, but ..`, `Did you mean to call this expression?`, `Did you forget to use 'await'?`
- The labels of tuple elements are kept, and show in messages.
- `keyof Shape` and `IteratorResult<T, TReturn>` go by those names.
- `<a b />` gives `b` the type `true`.
- A generic function nothing is inferred from is left out of the first round of overload resolution too.
Every test had a limit of 60 seconds. CI gives a test 90, and 270 under ASAN, so
there it was a lower limit, and for `bun bd test` it hid that three tests take
longer than the default of 5 seconds on a debug build: those with two processes
for each configuration file. A debug build has a smaller sample of them now, and
every third of the interfaces that extend a type made of their own members. The
programs from the reports, and the roots that are lists, are always all there.

On a debug build the slowest test takes 2.9 s, and the file 7.6 s, not 18.4 s.

[skip size check]

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

Comment thread src/runtime/cli/mod.rs Outdated
It printed the usage of `bun run` and exited with 0, so nothing was checked and it
looked like a pass. `bun --check` was `bun check` already, and the help for the
flag says so under `bun run --help` too. The same for `bun --check run`,
`bun run --check --` and with other flags next to it.

What "nothing to run" means was there, for `bun run -i`. Both use it now.

[skip size check]
…its order

`tsc -b` reports project by project, in build order. An error in a file that two
projects include is there once for each, and so is an error without a file, like
a type library that both name and that is not installed. `bun check` sorted the
errors of all projects together and printed each once.

- The errors of a project are sorted. Those of a build are not, and none is left
  out.
- The totals are those of `tsc -b --pretty`: a file that two projects include
  counts for each ("Found 8 errors in 6 files"), and is in the list of files
  twice.
- A referenced project that does not exist (TS6053) is reported where it is in the
  build order, not first. It was also reported once for every reference to it,
  which did not show as long as each error was printed once.
- With more than 50 errors, those without a file that are there several times have
  no place: no `:0`, no "on this line", no `<also>`.

differential.test.ts compared sorted lines, so it could not see the order. It
compares them as they are printed now, in every test, and has 189 graphs of three
projects: the order at the top, who references whom, a project that does not
exist, a type library that is missing.

Of the 317 configuration files with references in 70 open source projects, tsc -b
repeats a line in 26, 1,047 lines in all. All 317 are the same now, line by line.

[skip size check]

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment thread src/sema/driver/lib.rs
The projects of a build are read at the same time, through one view of the disk,
which had one list of the files that could not be read. Each project took the
whole list when it was done, so a TS5083 was printed with whichever project was
first: 7 different outputs in 12 runs of 6 projects. Until the last commit but one
all errors were sorted together, which hid it.

Each project has its own list now. One that starts again, because it needs the
output of another, starts with an empty list.

Docs: with `references`, a file that two projects include is checked in both. The
page said that each file is checked once.

[skip size check]
`Bun.build({ tsconfig: "./custom.json" })` is documented as `--tsconfig-override`,
and did nothing: the property was not read. The three tests for it pass without
it, since the `tsconfig.json` that is found anyway says the same in each.

Reading it was not enough. The resolver kept the file in its entry for the root of
the file system, from which every directory inherits, and those entries are shared
by all resolvers of the process. In a program that is running, that entry is there
long before the build. And a resolver with an override did not record the
`tsconfig.json` of the directories that it was the first to see, for anyone.

- The file belongs to the resolver, and to those of its workers. It is freed with
  them.
- What is recorded about a directory does not depend on it. So a build with the
  option, one without it, one with another file, and the imports of the program
  itself each get their own, also at the same time.
- With `check: true`, the type check reads it too.
- `--tsconfig-override` no longer prints "Internal error: directory mismatch for
  directory .. this indicates a bug", which it did every time: the file was read
  as if it were in the root directory.
- What is wrong with a `tsconfig.json` that is not read is still not an error.

[skip size check]

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment thread src/resolver/resolver.rs
With `--tsconfig-override configs`, or `tsconfig: "configs"` in `Bun.build`, the type
check read `configs/tsconfig.json`, as `tsc -p configs` does. The bundler and the
runtime could not read a directory, said nothing about it, and went on without any
tsconfig.json: `Could not resolve: "@/lib". Maybe you need to "bun install"?`, after
a check without errors.

They read the file in the directory now. If there is none, that is an error.

[skip size check]
…the error type

    interface D extends Partial<D> { m(): void }
    type Keys = keyof Partial<D>;

Next to the TS2310, the key of the mapped type has a circular constraint (TS2313):
`keyof D` needs the members of `D`, which need its base types. From then on
typescript-go has the error type for the constraint type of that mapped type
(`getConstraintOfTypeParameter` is nil unless `hasNonCircularBaseConstraint`), and
without an `as` clause the constraint type is what `keyof` returns. So `Keys` is an
error type, and nothing is reported about what is made of it. Here it was
`"m"`, and errors followed that tsc does not have.

- A mapped type for which TS2313 is reported is remembered.
- So is one whose constraint type is asked for while it is being resolved. That is
  how it goes for `interface D<X> extends Partial<D<X>>` and `Partial<D<string>>`.
- `instantiateMappedType` instantiates the constraint type of the declared type, to
  see whether it is the wildcard type. That is not a resolution of the key.

The forms of base type in differential.test.ts all had an `as` clause, but for one.
With `Partial<T>`, `Readonly<T>`, `Required<T>`, `Pick<T, 'm'>` and two mapped types
written out, 440 of 4,212 generated cases differed from tsc with the declarations
in a file that is not checked, and 426 with each before its use. Now 16 and 2 do,
all with `{ [K in keyof T]: T[K] } & { extra: 1 }` as the source of a relation to
`D`. The test has the other 4,194 in both places.

No cost: playwright, nuxt and mikro-orm take as many instructions as before.

[skip size check]

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

    type Plus<T> = { [K in keyof T]: T[K] } & { extra: 1 };
    interface D extends Plus<D> { m(): void }
    const d: D = null! as Plus<D>;

had a TS2739 that tsc does not have: `Plus<D>` was left without `m`.

- `getReducedType` lists the properties of an intersection, and sets
  `resolvedProperties` when it has them all. Here that needs the members of `D`,
  which need the properties of `Plus<D>`, listed a second time while the mapped type
  in it has no member yet. typescript-go replaces that list when the first is done.
  Here it was kept.
- `getGenericObjectFlags` asks every member of an intersection whether it is
  generic, and for a mapped type that resolves the constraint type. So in
  `Plus<D> extends D ? 1 : 2` the members of `D` are asked for first.

All 4,212 generated cases agree with tsc now, with the declarations in a file that
is not checked and with each before its use, and differential.test.ts has them all.

No cost: playwright, nuxt and mikro-orm take as many instructions as before.

[skip size check]

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment thread src/sema/check/mapped.rs Outdated
    const isOdd = (n: number) => { if (n % 2) { return true } return false };
    const s: string = isOdd(1);

had the message twice:

    error TS2322: Type 'boolean' is not assignable to type 'string'.
      Type 'boolean' is not assignable to type 'string'.

The return type is the union of the fresh `true` and the fresh `false`.
`getUnionTypeFromSortedList` sets `TypeFlagsBoolean` on a union of any two boolean
literal types. Here it was set on the union of the two regular ones only, so this one
was not a primitive type, and what is wrong with a union that is not primitive is
explained for one of its members.

Found by breaking the source of zustand. Of the 1,664 generated cases that
differential.test.ts now has (13 ways to get a boolean, 16 types that it is not
assignable to, 8 ways to assign it), 640 differed from tsc.

[skip size check]
…is augmented

    import React from "react";
    React.useStat(0);

In a program with `@types/react-dom`, which has a `declare module "react"`, tsc says

    Property 'useStat' does not exist on type
    'typeof import("/app/node_modules/@types/react/index.d.ts")'.

and `bun check` said `typeof import("react")`. Without the augmentation both say
`typeof React`.

`getSpecifierForModuleSymbol` has three outcomes without an enclosing file: the name
of a source file's symbol, the name of an ambient module, and else the name of the
file that the symbol is declared in. The third was missing: what `export =` names was
taken for the ambient module that adds to it.

Found by breaking the source of zustand. Of the 144 generated cases that
differential.test.ts now has (what is exported, what is added, how it is imported,
three messages), 81 differed from tsc.

[skip size check]

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

    class C { constructor(private [a]: number[]) {} }

with `noUnusedLocals` was a segmentation fault. A parameter property that is never
read is reported by its name, and the name of a pattern was read as if it were an
identifier. tsc reports it (after TS1187) under the name of the symbol that stands for
a missing name, and now `bun check` does too.

    type A<T> = { [K in "a" as A<T[]>]: 1 };

overflowed the stack. Asking whether such a mapped type is generic instantiates the
`as` clause, which is the same question about `A<T[][]>`, and so on. The recursion was
only cut short when the same type came round again, and here every level is another
type. It is now cut short whenever half the stack is in use. The TS2322 of tsc is
reported; a TS2589 after it, which tsc does not have, is still there.

Both were found by reading the checker side by side with typescript-go's.

[skip size check]

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

Comment thread src/sema/check/mapped.rs Outdated
    declare const handlers: Record<string, () => void>;
    if (handlers.click) {}

was TS2774, "this condition will always return true". tsc asks for the symbol of the
name that is tested. For a name that no property declares that is the symbol of the
index signature (`getApplicableIndexSymbol`), which exists only if an index signature
is declared, so not for `Record`. And every name has that one symbol, so `o.bar()` in
the body of `if (o.foo)` is a use of it.

    Object.freeze(function* () { yield 1; });

was TS7024, and the function `() => any`. Where any type is expected, the contextual
signature of a function is its own. `getReturnTypeFromBody` then expects nothing of a
generator. Here its own return type was asked for while it was being resolved.

    interface I { m(): this extends { a: 1 } ? "yes" : "no" }

did not distribute over a union, which made `f(x as A | B)` a TS2345, and
`{ [K in keyof this]: .. }` was not homomorphic. tsc tests for the flag
`TypeFlagsTypeParameter`, which the `this` type has.

All three were found by reading the checker side by side with typescript-go's.
differential.test.ts has a generator for each: of 119, 140 and 252 cases, 55, and 85 of
the other two together, differed from tsc.

[skip size check]

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Still open from earlier reviews (3):

  • 🔴 src/sema/check/mapped.rs:94 — Users whose project reaches 1.5 MB of checker stack through ordinary deep types may now get a spurious TS2589 that tsc…
  • Also unresolved: 2 minor or pre-existing.

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

Comment thread src/sema/check/errors_small.rs Outdated
Comment on lines +406 to +410
let key_type = self.string_literal(name, false);
let is_declared = members.shape().index.iter().any(|info| {
info.declaration.is_some() && self.is_applicable_index_type(key_type, info.key)
});
is_declared.then_some(SymbolAtName::IndexOfSeveral)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Users whose object type has two index signatures that both apply to a name (two template-literal keys, say) get a spurious TS2774 and exit 1 from bun check/--check where tsc is clean. symbol_at_name returns SymbolAtName::IndexOfSeveral at errors_small.rs:410 when the applicable IndexInfo has no declaration, and same_property (errors_small.rs:421) treats two such results as never the same, so if (several.abz) { several.acz(); } counts as unused and errors. Fix: compare the set of applicable index-signature declarations, as getApplicableIndexSymbol does (one __index symbol when all apply, one cached symbol per declaration list otherwise), so names with the same applicable declarations are the same symbol.

Why this was flagged

Input: declare const several: { [k: a${string}]: F; [k: ${string}z]: F } and if (several.abz) { several.acz(); } (or several.abz() in the body), reached through check_known_truthy_types (errors_small.rs:207-256). symbol_at_name (errors_small.rs:388-411) calls applicable_index_info_for_name; with two applicable infos applicable_index_info (shape.rs:6253-6263) builds IndexInfo::new(..) whose declaration is None (types.rs:549-557), so the result is SymbolAtName::IndexOfSeveral and is_named is true at :212-214. In is_mention_of (:356) same_property hits the _ => false arm at :421 for (IndexOfSeveral, IndexOfSeveral), so is_used is false and error_at(.., 2774, ..) fires at :254. In TypeScript's getApplicableIndexSymbol the tested name and the body name resolve to the same __index symbol, so isSymbolUsedInConditionBody is true and no error is reported. Base branch has no bun check.

Verification: Triggered when an object type has two non-string index signatures that both apply to the tested name, with the same property-name mentioned in the body. same_property (errors_small.rs:414-423) has no arm for (IndexOfSeveral, IndexOfSeveral) and falls to _ => false, so error_at(.., 2774, ..) fires at line 254. bun check prints a spurious TS2774 and exits 1 where tsc is clean.

Comment thread src/sema/check/errors_small.rs Outdated
Comment on lines +207 to +217
let is_named = matches!(
hir[location].kind,
ExprKind::Ident(_) | ExprKind::Dot { .. }
);
// `testedSymbol == nil`
let is_named = is_named
&& (matches!(hir[location].kind, ExprKind::Ident(_))
|| self.symbol_at_name(file, location).is_some());
if !is_named && !is_promise {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): Users get no TS2774 from bun check for a condition that is an assignment or comma expression ending in a function name, where tsc reports one. At src/sema/check/errors_small.rs:207-217 is_named accepts only ExprKind::Ident and ExprKind::Dot, so if (x = fn) {} or if (setup(), fn) {} returns before the use check. TypeScript's testedNode also takes location.right when location is a binary expression whose right operand is an identifier. Fix: treat a non-logical Binary whose right is an Ident as named, resolve the symbol of that identifier, and run the same is_used search for it, so the report matches tsc for the assignment and comma forms too.

Why this was flagged

A project has declare function fn(): void; declare let x: () => void; and a statement such as if (x = fn) { run(); } or if (setup(), fn) { run(); }, checked with strictNullChecks through bun check or --check. check_known_truthy_type (src/sema/check/errors_small.rs:145) computes location = test because the operator is not &&, || or ?? (:156-163); ty is the type of the whole expression, a function type, so self.signatures(ty, false) is not empty (:204). is_named at :207-210 is false for ExprKind::Binary, and !is_named && !is_promise at :215 returns without reporting. In TypeScript the tested node is location.right when location is a binary expression with an identifier on the right, so getSymbolAtLocation finds fn, the body does not mention it, and tsc reports TS2774: This condition will always return true since this function is always defined. Did you mean to call it instead? and exits 1. bun check prints No type errors and exits 0 for the same program. The base branch has no bun check.

Verification: check_known_truthy_type sets location = test for anything that is not &&/||/?? (src/sema/check/errors_small.rs:156-163); is_named (207-210) matches only ExprKind::Ident(_) | ExprKind::Dot { .. }, so if !is_named && !is_promise { return; } (215-217) returns before the use check and no TS2774 is emitted. tsc reports error TS2774 on x = fn / setup(), fn, while bun check exits 0.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rewrite in 07e56a2 replaced is_named with a tested_node match at src/sema/check/errors_small.rs:234-238, but that match still only has Kind::Identifier and Kind::PropertyAccessExpression arms and falls to Node::NONE otherwise, so if (x = fn) {} and if (setup(), fn) {} still return at tested_symbol.is_none() && !is_promise without a TS2774. TypeScript's testedNode has a third arm, isBinaryExpression(location) && isIdentifier(location.right) ? location.right. Adding a Kind::BinaryExpression arm that yields the right operand when it is an identifier (the logical operators are already excluded by the is_logical(location) return above) would make the assignment and comma forms report like tsc.

tsc has one function that says whether a type is generic as an object (`T`, a generic
mapped type, a tuple with `...T`), as an index (`T`, `keyof T`, a template with `T`), or
both, and `isGenericObjectType`, `isGenericIndexType` and `isGenericType` read it.
Whichever is asked, a mapped type resolves its constraint type on the way.

Here the three were written separately. The one for index types did not look at mapped
types. The one for either was asked in six places where tsc asks for an index type: for
the constraint and the `as` clause of a mapped type, the index of an indexed access, a
placeholder of a template, the argument of `Uppercase`, and the argument of an element
access. And `const { a, ...rest } = v` asked it of both the object and the keys.

    type Brand<T> = { [P in keyof T]: 1 };
    type M<T> = { [K in "a" | "b" as K & Brand<T>]: 1 };
    function f<T>() { const x: M<T> = {}; }

was a false TS2322, and `{ a: 1 }[{ [K in keyof T]: 1 }]` was TS2536 in place of TS2538.

Now there is the one function, with its cache for unions and intersections, and the three
read it. Of the 156 generated cases that differential.test.ts now has (13 kinds of type in
12 places), 20 differed from tsc.

[skip size check]

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no new issues

No new issues were found in this update; 4 findings from earlier reviews are still open above.

Still open from earlier reviews (4):

  • 🔴 src/sema/check/errors_small.rs:410 — Users whose object type has two index signatures that both apply to a name (two template-literal keys, say) get a spuri…
  • Also unresolved: 2 minor or pre-existing, 1 blocking on lines changed since (possibly already fixed).

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

A composite project has to list every file that it imports (TS6307), and every file has
to be under `rootDir` (TS6059). The sources of a referenced project are exempt: that
project emits them.

`sourceFileMayBeEmitted` asks whether the file is a source of a referenced project. Here
the question was whether the project has any references at all, so one reference
silenced both errors for every imported file, `../package.json` included. The table that
answers the real question was already there.

Found by reading the program side by side with typescript-go's. Of the 75 projects that
differential.test.ts now generates (what is imported, the options, the references), 24
differed from `tsc -b`. The 317 configs with references in the surveyed repos are as
before.

[skip size check]

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Still open from earlier reviews (4):

  • 🔴 src/sema/check/errors_small.rs:410 — Users whose object type has two index signatures that both apply to a name (two template-literal keys, say) get a spuri…
  • Also unresolved: 2 minor or pre-existing, 1 blocking on lines changed since (possibly already fixed).

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

Comment thread test/cli/check/differential.test.ts
Every function of typescript-go's checker, binder, module resolver, config parser, program
and build mode was read next to its counterpart here. Where the two can behave
differently there is now a small program that shows it, with what tsc prints: 1,794 of
them. Most are in corners: next to another error, without the default library, in code
nobody writes on purpose.

This takes the ones that ordinary or merely unusual code can reach, and fixes them by
function, not by program: the counterpart gets the branches of the original, in its
order, asking its questions, and what stood in for the real thing is deleted. Names
compared where tsc compares symbols, syntax walked where tsc tracks what is being
resolved, a type's members where tsc takes its apparent type.

652 of the 1,794 programs now print what tsc prints, up from 26. Of the 27 that ordinary
code reaches, 26. 226 of the ones that were not looked at came along, because a function
that is whole is right about more than the program that gave it away.

Among them: TS1361 for `import type React` with "jsx": "react"; TS2862 for `t[k]++` on a
generic `t`; TS2820 with its suggestion for a string assigned to a string enum; a class
that inherits its constructor compared with another; an interface merged with a namespace
where an index signature is expected; `await` of `ReturnType<T>`; errors at the element of
`[a, ...(c ? [x] : []), b]` that is wrong; TS1192 for a module of a referenced project;
`explainFiles`.

A mapped type whose `as` clause instantiates itself is cut short only while the same
declaration is in progress, not whenever the stack is half used.

differential-cases.json has the 641 programs that can run anywhere, and
differential.test.ts runs tsc and `bun check` on each.

TypeScript's own tests, the 637 configs of the surveyed repos and the 317 with references
are as before. Checking costs 2 to 5% more instructions on playwright, nuxt and hono.

[skip size check]
The file that the last commit left out, and does not compile without: `Request` has a
`build` field, and `bun check -b <project>` sets it. The argument names the project,
which is checked as `tsc -b` builds it.

[skip size check]
With a third variant it is 72 bytes, which clippy refuses by value.

[skip size check]

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Still open from earlier reviews (3):

  • Unresolved: 2 minor or pre-existing, 1 blocking on lines changed since (possibly already fixed).

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

Comment thread src/runtime/cli/check_command.rs
Comment thread src/runtime/cli/check_command.rs Outdated
    bun -b check src/a.ts
    BUN_OPTIONS=-b bun check src/a.ts

took `-b` for `--build`, whose argument is the project, so `src/a.ts` was read as a
tsconfig.json: "'{' expected". What precedes `check` is for `bun`, and there `-b` is the
short form of `--bun`. `--build` and `-b` now count only where they follow `check`, so
`bun check -b` is still what `tsc -b` is.

Also: a failed allocation while a package script is noted goes the way of every other,
not into a panic that says "unreachable".

[skip size check]

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Still open from earlier reviews (3):

  • Unresolved: 2 minor or pre-existing, 1 blocking on lines changed since (possibly already fixed).

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

Comment on lines +109 to +111
build: (args.iter().map(|arg| arg.as_bytes()))
.take_while(|arg| *arg != b"--")
.any(|arg| arg == b"-b" || arg == b"--build"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): Users who chain the short flags, as in bun check -bp tsconfig.json, now get a plain check instead of the tsc -b style build check, with no error. The scan at check_command.rs:109-111 only recognises the exact arguments -b and --build, while bun_clap still parses -bp as -b plus -p (chained shorts, src/clap/lib.rs:236-240), so the flag is accepted and silently dropped. Fix: derive build from the parsed flag (parsed.flag(b"--build")) and only mask it when the -b sits before check in argv, so every spelling clap accepts after check (-b, --build, -bp …) means build mode.

Why this was flagged

The latest push changed build from parsed.flag(b"--build") to a byte scan of the arguments after check at src/runtime/cli/check_command.rs:109-111, matching only -b or --build exactly. bun_clap chains short flags that take no value (src/clap/lib.rs:236-240), so bun check -bp tsconfig.json is parsed as -b and -p tsconfig.json, with --build set in parsed, but the scan sees -bp and sets build: false. exec_with (check_command.rs:628-631) then uses Paths::Arguments instead of Paths::Build; the driver injects noEmit: true (src/sema/driver/lib.rs:414) and does not take the check_with_references branch for a project without references (lib.rs:1383), so the result differs from bun check -b -p tsconfig.json and from tsc -b, and the user is not told the flag was ignored. On the previous push (c83ada6) -bp worked because the value came from clap. No test covers the chained spelling; test/cli/check/check.test.ts:15002-15005 only uses -b and --build on their own.

Verification: nit — triggers when a user chains the short flags after check, e.g. bun check -bp tsconfig.json. src/runtime/cli/check_command.rs:109-111 derives build by scanning the raw args for exactly -b or --build, while bun_clap chains such shorts (src/clap/streaming.rs:232-239), so -bp tsconfig.json parses without error but the scan leaves build: false. No test covers chained shorts.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants