`bun check` - a TypeScript type checker builtin to Bun
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 thetsconfig.jsonthat Bun finds by itself says the same thing. Now that build reads the file in place of everytsconfig.json, like--tsconfig-override. For both, a directory means thetsconfig.jsonin it, liketsc -p. Other builds in the same process, and the program's own imports, aren't affected, even while it's running.--tsconfig-overrideprintedInternal error: directory mismatch for directory "/app/custom.json", fd 3. You don't need to do anything, but this indicates a bug.every time, forbun buildand forbun run.- On Linux without
pidfd_open, a child process that exits could interrupt a system call of the main thread withEINTR. Code that doesn't retry, like an addon orbun:ffi, saw a failedread(). It also madespawn.test.tsfail in about half of the ASAN builds of this branch.
Intentional differences from tsc
- No emit.
noEmit,declarationandincrementaldon't change anything. bun checkhas no--watch.bun --watch --check <file>checks before every restart.- Language service plugins (
pluginsincompilerOptions) aren't loaded. listFiles,listFilesOnlyandtraceResolutionwork. Other options that only print things, likeexplainFiles, are ignored.- Messages that tell you to run
npm i --save-devsaybun add -d. - The
lib.*.d.tsfiles are built in. A path in one of them is printed asbundled:///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.jsonisn't installed, a note names thepackage.jsonand says how to runbun installfor 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 adeclare moduleblock in another file.tsc6.0.2 reports it too.tsc7.0.2 only does when both are in one file. acc = apply(run, ["x", acc])in a loop, where the parameter is a tuple:tsc7.0.2 reports TS2345 because the array literal loses its tuple type.tsc6.0.2 doesn't, andbun checkdoesn'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).
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 checkis 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
-bbuild 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,definePropertyand so on). - It's a port. 1,791 of the 2,770 functions in typescript-go's
internal/checkerare 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 oftscas 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 about | Programs |
|---|---|
an error that tsc reports | 671 |
an error that tsc doesn't report | 538 |
| the words of a message | 419 |
| the position of an error | 55 |
| the order of the errors | 22 |
| a crash | 1 |
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.
| 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 | 5.98 s | 1.24 s | 4.8x |
| mikro-orm | 2,883 | 5.97 s | 1.20 s | 5.0x |
next.js packages/next | 2,881 | 1.82 s | 0.28 s | 6.4x |
| next.js, root | 3,547 | 1.28 s | 0.42 s | 3.1x |
storybook scripts | 1,039 | 0.96 s | 0.27 s | 3.5x |
| nuxt | 839 | 0.80 s | 0.27 s | 3.0x |
| playwright | 706 | 0.64 s | 0.15 s | 4.2x |
lit packages/react | 6 | 2.12 s | 0.46 s | 4.6x |
CPU time:
| Project | Files | tsc 7.0.2 | bun check | Less |
|---|---|---|---|---|
vscode src | 9,795 | 32.1 s | 15.2 s | 2.1x |
| mikro-orm | 2,883 | 29.4 s | 11.2 s | 2.6x |
next.js packages/next | 2,881 | 6.8 s | 2.8 s | 2.5x |
| next.js, root | 3,547 | 5.2 s | 4.2 s | 1.2x |
storybook scripts | 1,039 | 4.8 s | 1.8 s | 2.6x |
| nuxt | 839 | 4.2 s | 1.7 s | 2.5x |
| playwright | 706 | 3.6 s | 1.3 s | 2.8x |
lit packages/react | 6 | 2.8 s | 0.5 s | 5.6x |
Peak memory:
| Project | Files | tsc 7.0.2 | bun check | Less |
|---|---|---|---|---|
vscode src | 9,795 | 7.97 GB | 2.14 GB | 3.7x |
| mikro-orm | 2,883 | 6.67 GB | 1.35 GB | 4.9x |
next.js packages/next | 2,881 | 1.81 GB | 0.71 GB | 2.5x |
| next.js, root | 3,547 | 1.33 GB | 0.55 GB | 2.4x |
storybook scripts | 1,039 | 1.39 GB | 0.56 GB | 2.5x |
| nuxt | 839 | 1.14 GB | 0.51 GB | 2.2x |
| playwright | 706 | 1.06 GB | 0.46 GB | 2.3x |
lit packages/react | 6 | 0.63 GB | 0.24 GB | 2.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:
| 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
tscon real projects only ran on macOS arm64. CI builds this for Windows x64 and arm64, macOS, Linux glibc and musl, Android and FreeBSD, and runstest/cli/checkwherever it runs Bun's tests. - ThreadSanitizer hasn't run on this version.
- Most of the
unsafecode 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 checkgives 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'spackages-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 incompatiblewhere 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
strictoff: 7 of typescript-go's 74 errors are missing, and there are 5 that it doesn't report. - Single-quoted strings in
package.jsonare accepted. typescript-go rejects them. interface Foo extends Partial<Foo>, or any other mapped type ofFoo, is an error in both (TS2310). Whattscsays after it depends on what's asked for first,FooorPartial<Foo>. Within a filebun checksays the same. Across files it can differ, becausetscgoes by the order of the whole program. WithskipLibCheckoff, an interface of the library that's extended like that can have members inbun checkthat it doesn't have intsc.- More than 332 variables without annotations in one loop, each initialized with the one before it:
bun checksays 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 generict: T,for (const [k, v] of Object.entries(t)) v.foogets TS18046,'v' is of type 'unknown', wheretscaccepts it. 38 are in unusual code or unusual command lines. For example,function f({ a = 1, b = g(a) }) {}gets a false TS7022, and inenum E { A = 1, B = next(E.A) }withfunction next(e: E)a false TS2502. In a directory with ajsconfig.jsonand notsconfig.json,bun checkchecks that project wheretscgoes on to the parent'stsconfig.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.