`bun check` - a TypeScript type checker builtin to Bun

Merged
JA
1mo ago
+229088-1344

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 timePeak memory
tsc 6.0.214.8 s2.33 GB
tsc 7.0.2 (typescript-go)6.9 s1.78 GB
bun check2.4 s0.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

FlagWhat it does
-p, --project <path>A tsconfig.json, or the directory that holds one
--pretty / --no-prettySource around each error, or one line per error
--allShow every error instead of grouping identical ones above 50
--threads <n>Number of threads. Default: one per core
--timingPrint 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 aTS7008 on c
tsc 6.0.2yesyes
tsgo 7.0.2 --singleThreaded or --checkers 1yesyes
tsgo 7.0.2, defaultnoyes
all of the above, with a_user.js renamed to zz_user.jsnoyes
bun check, either nameyesyes

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).

Projecttsconfig.json filesIdenticalErrors in tscErrors in bun checkMessages worded differently
vercel/ai52529,7429,7420
anthropics/anthropic-sdk-typescript16165965960
apollographql/apollo-client775595590
arktypeio/arktype553893890
withastro/astro12121,9961,9960
axios/axios111051050
vuejs/core651,3591,3580
date-fns/date-fns33110
discordjs/discord.js22330
drizzle-team/drizzle-orm3310,78610,7860
Effect-TS/effect1010220
elysiajs/elysia4453530
excalidraw/excalidraw7711,04811,0480
fastify/fastify11000
TanStack/form13131241240
gcanti/fp-ts22330
sindresorhus/got11220
graphql/graphql-js3324240
unjs/h311000
honojs/hono6614,23914,2390
immerjs/immer11550
gcanti/io-ts22330
pmndrs/jotai22770
sindresorhus/ky2252520
kysely-org/kysely661,1851,1850
langchain-ai/langchainjs41415,2735,2730
lit/lit34342,5882,5880
mikro-orm/mikro-orm171787,98187,9810
mobxjs/mobx331861860
mswjs/msw10105,8785,8780
nestjs/nest303027,34527,3450
vercel/next.js3563563,6903,6900
unjs/nitro557427420
nuxt/nuxt3311110
openai/openai-node44660
microsoft/playwright4422220
preactjs/preact11110
prisma/prisma444424,20124,2010
puppeteer/puppeteer10102,7012,7010
TanStack/query15156866860
react-hook-form/react-hook-form22330
remix-run/react-router14146,7356,7350
reduxjs/redux11000
reduxjs/redux-toolkit881421420
remeda/remeda7713130
rollup/rollup991071070
TanStack/router29294,8534,8530
ReactiveX/rxjs101022,27122,2717
sequelize/sequelize16166,7686,7680
solidjs/solid777407400
storybookjs/storybook19191,4011,4010
stripe/stripe-node4442420
sveltejs/svelte551861860
vercel/swr443143140
TanStack/table20205745740
tldraw/tldraw44000
gvergnaud/ts-pattern22220
total-typescript/ts-reset22440
millsp/ts-toolbelt11330
sindresorhus/type-fest22000
typeorm/typeorm449599590
microsoft/TypeScript446136130
typescript-eslint/typescript-eslint353518,39218,3920
urql-graphql/urql2217170
fabian-hiller/valibot22110
pmndrs/valtio11000
vitest-dev/vitest18182,1112,1110
microsoft/vscode, src/tsconfig.json11000
vscode's extensions, tests and base configs1051055,6855,6850
statelyai/xstate885455450
jquense/yup11110
colinhacks/zod10101911910
pmndrs/zustand11000
all 721,1031,102286,267286,2667

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:

PackageVersionFilesErrors in tscErrors in bun checkMessages worded differently
<%= dasherize(name) %>0.0.06110
@actions/github-script7.0.17000
@arrows/array1.4.197000
@arrows/composition1.2.228440
@arrows/dispatch1.0.311000
@arrows/error1.0.23220
@arrows/multimethod1.4.112440
atomically1.7.01126260
@aws-crypto/sha1-browser5.2.0514140
@aws-crypto/sha256-browser5.2.0514140
@aws-crypto/sha256-js5.2.0515150
@aws-crypto/util5.2.0515150
@azure/arm-appservice15.0.06912120
@azure/arm-resources5.2.025220
@bcoe/v8-coverage0.2.38660
benny3.7.11310100
@braintree/sanitize-url7.1.14110
@braintree/sanitize-url7.1.24110
comment-parser1.2.43525250
comment-parser1.4.13527270
comment-parser1.4.53527270
comment-parser1.4.63527270
comment-parser1.4.73527270
comment-parser1.4.835110
comment-parser1.4.935110
@compodoc/ngd-core2.1.13000
@compodoc/ngd-transformer2.1.3336360
convex1.28.21741351350
convex-helpers0.1.10430110
csp_evaluator1.1.122000
devcert1.2.31114140
dexie-react-hooks4.4.023000
editions6.22.04660
@electric-sql/pglite-socket0.0.68220
@electric-sql/pglite-socket0.1.39220
@electric-sql/pglite-socket0.2.710330
@electric-sql/pglite-tools0.2.77220
@electric-sql/pglite-tools0.3.37220
@embroider/reverse-exports0.2.0386860
example-typescript1.0.0713130
expo-asset11.1.52580800
expo-asset12.0.132574740
expo-asset57.0.182694940
expo-constants17.1.6669690
expo-constants18.0.13667670
expo-constants57.0.19687870
expo-file-system18.1.101879790
expo-file-system19.0.222252520
expo-file-system57.0.73670700
expo-font13.3.11918180
expo-font14.0.112156560
expo-font57.0.424220
expo-haptics15.0.8453530
expo-image3.0.113294940
expo-keep-awake14.1.44550
expo-keep-awake15.0.8452520
expo-keep-awake57.0.2470700
expo-linking8.0.121369690
expo-modules-autolinking2.1.1022330
expo-modules-autolinking3.0.2540220
expo-modules-autolinking57.0.1346110
expo-modules-core2.3.134125250
expo-modules-core3.0.304457570
expo-modules-core57.0.184675750
expo-splash-screen31.0.13453530
expo-sqlite14.0.61434340
expo-status-bar3.0.9556560
expo-status-bar57.0.1672720
expo-symbols1.0.8555550
expo-system-ui6.0.9852520
expo-web-browser15.0.11752520
@expo/devcert1.2.01125250
@expo/devcert1.2.11184840
@expo/dom-webview57.0.1876760
@expo/log-box57.0.44274740
feed4.2.212000
@glimmer/component2.1.14550
@huggingface/jinja0.3.46000
human-id4.1.13000
human-id4.1.33000
human-id4.2.03000
human-id4.2.13000
import-in-the-middle1.14.03220
import-in-the-middle1.15.03220
import-path-rewrite0.0.14550
junit-xml1.2.05110
khroma2.1.0501481480
@layerup/layerup-security1.6.05000
@lokalise/node-api12.8.0226110
merge-anything2.4.43660
mimetext3.0.288770
@monaco-editor/react4.7.03440
mongodb6.21.013164640
mongodb7.5.0131110
mongodb7.6.0132110
mongodb7.7.013262620
moo-color1.0.31233330
next-mdx-remote-client1.1.215110
npx-import1.1.44110
oblivious-set2.0.0314140
onigasm2.2.5533330
openapi3-ts2.0.21211110
opencontrol0.0.6552520
path-data-parser0.1.04000
pickleparser0.2.1192782780
pino10.3.017330
pino10.3.117330
pino8.17.21742420
pino9.14.01742420
piscina3.1.02834340
piscina4.8.04646460
pkg-pr-new0.0.534880
pkg-pr-new0.0.71444440
pkg-pr-new0.0.72412120
pkg-pr-new0.0.75412120
pkg-pr-new0.0.87448480
pkg-pr-new0.0.88448480
@pnpm/config.env-replace1.1.0311110
@pnpm/network.ca-file1.0.2311110
protractor7.0.07110
react-static-example-typescriptNone1017170
@redocly/openapi-core1.34.2027849490
resolve-package-path1.2.741161160
resolve-package-path2.0.041161160
resolve-package-path3.1.041041040
rxjs7.3.0250220
rxjs7.8.1251110
rxjs7.8.2251110
signal-polyfill0.2.22514140
@stablelib/base641.0.1315150
@statelyai/inspect0.4.01122220
@streamparser/json0.0.2129110
@supabase/ssr0.5.212440
template0.0.03563630
tunnel-rat0.1.23000
unfurl.js6.4.01416160
uri-js4.2.210110
@vercel/agent-eval-playground0.1.3401061060
@verdaccio/core8.0.0-next-8.119330
@verdaccio/logger-prettify8.0.0-next-8.0915150
@verdaccio/url13.0.0-next-8.16220
@verdaccio/utils7.0.1-next-8.111770
vite-plugin-externalize-deps0.10.04000
vscode-tas-client0.3.37000
@wdio/xvfb9.27.2516160
webpod0.0.24880
with7.0.2310100
workbox-core7.3.03034340
workbox-core7.4.13034340
@workflow/world5.0.0-beta.2729440
yaml-ast-parser0.0.43381671670
all 1514,0964,4964,4960

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.

OptionsProjectsIdenticalErrors in tscErrors in bun check
every strictness flag on6969100,756100,756
strict off696876,96076,958
skipLibCheck off696990,60990,609
checkJs696984,12284,122
declaration696980,49580,495
isolatedModules with verbatimModuleSyntax696980,62280,622
isolatedDeclarations626234,98234,982
erasableSyntaxOnly with legacy decorators696971,57771,577
nodenext696985,23885,238
"types": []6969105,818105,818
"lib": ["es5"]696983,87283,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.

BaselineWhat it holdsTestsIdentical
.errors.txtevery error, with the source around it13,10113,101
.typesthe printed type of every expression and name12,46312,463
.symbolsthe symbol of every name, with where it is declared12,46312,463
.jsthe declaration files that are emitted, and the errors in them13,03213,032
.trace.jsonthe log of how every import was resolved, with traceResolution151151

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 generatesFunctionsWhat has to match
narrowing: the reference, the guard, the control flow around it, what happens before the use46,080every error and its message
variables without a type annotation29,484every error and its message
calls with callbacks: the callee, the form of the callback, the context of the call9,984every error and its message
circular references, directly and through a second declaration9,196every error and its message
a variable in a loop that's assigned back, in 12 kinds of loop20,304which 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 literal4,522every line tsc prints
index signatures: the key of the source and the key of the target, in assignments, inference and redeclarations1,248every line tsc prints
a circular reference next to a call with overloads and a callback3,888every 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 uses4,212every line tsc prints
the same in a file that's checked, with the declaration before its use4,212every line tsc prints
a conditional type inside another one that checks the same type, with infer480every line tsc prints
a boolean: 13 ways to get one, 16 types it isn't assignable to, 8 ways to assign it1,664every line tsc prints
a package with export =: what it exports, what declare module adds to it, how it's imported, 3 messages144every line tsc prints
a parameter property that is a pattern: 5 modifiers, 5 patterns, 3 sets of options75every line tsc prints
a function from an index signature that is tested and not called: 11 receivers, 11 tests119every line tsc prints
a generator with literals where any type is expected: 14 contexts, 10 generators140every line tsc prints
a conditional or a mapped type over this: 9 types, 28 uses252every line tsc prints
a type that is generic as an object, as an index, as both or as neither: 13 types, 12 places156every line tsc prints
what a project with references imports: 5 imports, 5 sets of options, 3 sets of references75every 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 generatesCasesWhat has to match
how a file is referred to (8 forms of import and reference, packages, files), in which spellings, in which order, from where150every line tsc prints
the same where case matters, plus 2 real files like Button.ts and button.ts167every line tsc prints
a project reference that's spelled differently from the directory, or in 2 spellings27every line tsc -b prints
tsconfig.json with a value that isn't JSON, like "strict": tru: 28 kinds of value in 30 places840every line tsc prints
tsconfig.json whose root is a list, like [{ "compilerOptions": {} }]9every 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 missing189every 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 program44every 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 generatesProgramsDifferent from tsc
classes: access, overriding, initialization, heritage, super, references to itself, 2 members with one name290,0000
overloads, inference, variance (21 groups)1,300,0000
JSX: 48 tags, 48 lists of attributes, 23 forms of children63,7700
where a long type is cut off in a message1,060,0000
2 declarations of one variable or property123,8910
module augmentation through re-exports and aliases2101
JSDoc tags on every kind of host, in checked JavaScript68,3340
class expressions that refer to what holds them40,12821
type annotations inside an expression that refer to what's being declared50,68895
symbols that tsc creates lazily15,44425
immediately invoked functions19,20021
type aliases that refer to themselves without end, through 36 kinds of type51728

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. 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 ran each program again and tried to refute it.

1,706 of those programs are in test/cli/check/differential-cases.json. differential-cases-1.test.ts to differential-cases-4.test.ts run tsc 7.0.2 and bun check on each, on every platform where CI runs Bun's tests, and the output has to be the same. Windows skips 3 that need a directory named c:.

What the program is aboutPrograms
an error that tsc reports671
an error that tsc doesn't report538
the words of a message419
the position of an error55
the order of the errors22
a crash1

The programs on which the two disagree are under "Known problems".

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.

MutationsErrors in tscDifferent error codesMissing in bun checkOnly in bun checkWorded differently
9132,92567008

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:

ProjectFilestsc 7.0.2bun checkFaster
vscode src9,7955.98 s1.24 s4.8x
mikro-orm2,8835.97 s1.20 s5.0x
next.js packages/next2,8811.82 s0.28 s6.4x
next.js, root3,5471.28 s0.42 s3.1x
storybook scripts1,0390.96 s0.27 s3.5x
nuxt8390.80 s0.27 s3.0x
playwright7060.64 s0.15 s4.2x
lit packages/react62.12 s0.46 s4.6x

CPU time:

ProjectFilestsc 7.0.2bun checkLess
vscode src9,79532.1 s15.2 s2.1x
mikro-orm2,88329.4 s11.2 s2.6x
next.js packages/next2,8816.8 s2.8 s2.5x
next.js, root3,5475.2 s4.2 s1.2x
storybook scripts1,0394.8 s1.8 s2.6x
nuxt8394.2 s1.7 s2.5x
playwright7063.6 s1.3 s2.8x
lit packages/react62.8 s0.5 s5.6x

Peak memory:

ProjectFilestsc 7.0.2bun checkLess
vscode src9,7957.97 GB2.14 GB3.7x
mikro-orm2,8836.67 GB1.35 GB4.9x
next.js packages/next2,8811.81 GB0.71 GB2.5x
next.js, root3,5471.33 GB0.55 GB2.4x
storybook scripts1,0391.39 GB0.56 GB2.5x
nuxt8391.14 GB0.51 GB2.2x
playwright7061.06 GB0.46 GB2.3x
lit packages/react60.63 GB0.24 GB2.6x

Measured with other work running on the machine (load average 6 to 13), and 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:

TemplateFiles checkedErrors
bun init -y2440
bun init --react2540
bun init --react=tailwind2560
bun init --react=shadcn2790

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:

ProjectInstalled typescriptErrors
dayjs2.9.20
immer5.0.25
svelte5.5.462
zod5.5.431
drizzle-orm5.6.310,845
solid5.7.3352
vercel/ai5.8.31,561
lit5.9.21,891
apollo-client5.9.3461

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 hasn't run on this version.
  • 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.
  • On 254 of the small programs written while reading the two side by side, the output differs. 1 is ordinary code: after if (t === null || t === undefined) return; on a generic t: T, for (const [k, v] of Object.entries(t)) v.foo gets TS18046, 'v' is of type 'unknown', where tsc accepts it. 38 are in unusual code or unusual command lines. For example, function f({ a = 1, b = g(a) }) {} gets a false TS7022, and in enum E { A = 1, B = next(E.A) } with function next(e: E) a false TS2502. In a directory with a jsconfig.json and no tsconfig.json, bun check checks that project where tsc goes on to the parent's tsconfig.json. The other 215 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 5.94 to 7.21 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.