bun check - a TypeScript type checker builtin to Bun - #44361
Jarred-Sumner wants to merge 291 commits into
Conversation
|
Updated 5:47 AM PT - Oct 6th, 2026
❌ @Jarred-Sumner, your commit 93975d8 has 3 failures in
Add 🧪 To try this PR locally: bunx bun-pr 44361That installs a local version of the PR into your 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.
…ent things can both be noted
…th it; helpers for related information
…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]
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]
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]
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]
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]
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]
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]
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]
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
🔴 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.
| 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; | ||
| } |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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]
There was a problem hiding this comment.
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]
There was a problem hiding this comment.
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.
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]
There was a problem hiding this comment.
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.
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]
There was a problem hiding this comment.
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.
| build: (args.iter().map(|arg| arg.as_bytes())) | ||
| .take_while(|arg| *arg != b"--") | ||
| .any(|arg| arg == b"-b" || arg == b"--build"), |
There was a problem hiding this comment.
🟡 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.
What does this PR do?
This adds a TypeScript type checker to Bun.
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. Ifbun checkandtsc7 disagree about an error, that's a bug in Bun.packages/nextin the Next.js repo, 2,881 files:tsc6.0.2tsc7.0.2 (typescript-go)bun check(How that was measured is under Performance.)
It only type checks. It doesn't emit JavaScript,
.d.tsfiles, 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 checklooks for the nearesttsconfig.jsonand checks whateverfiles,includeandexcludeselect. It uses all of your CPU cores.Errors go to stdout. The summary goes to stderr.
If your
package.jsonalready has acheckscript,bun checkstill runs that script, like it does today.bun --checkwith no file is the type checker in every project."check": "bun check"works too: inside that script, and in the scripts it runs,bun checkis the type checker.bun --filter '*' checkandbun --workspaces checkstill run each package'scheckscript.Check some files
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 hasreferences, the referenced project that does. So it works in the layoutcreate vitegives you.A directory means the part of the project that's in it, so
bun check .is the same asbun check. If the project has no files there, likebun check scriptswhenincludeis["src"], Bun checks every file in the directory that isn't excluded, each with thetsconfig.jsonnearest to it.Choose a tsconfig
Without a tsconfig
bun check index.tsworks 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.jsonat the root,bun checkchecks everything below the current directory. Each file gets the options of thetsconfig.jsonnearest to it, and each file is only checked once.Project references
referencesare followed liketsc -bdoes, 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, liketsc -bdoes.--check--checktype 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.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 --checkrestarts the process like--watchdoes, so the code is checked again before it runs.A file without an extension,
-e,-pand 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.--conditionsand--loaderapply to the check: with--loader .js:ts, every.jsfile of your project is TypeScript.--checkdoesn't cover files loaded with--preload.bun checkdoes, if yourtsconfig.jsonincludes them.bun --watch --check src/index.ts bun test --watch --checkA
package.jsonscript has no entry point to start from, so Bun checks the whole project first, likebun check. Same with--filter,--paralleland--sequential.bun run --check dev bun --check --filter '*' build--tsconfig-overrideapplies to the check too, so the type checker reads the sametsconfig.jsonas the bundler and the runtime. So doestsconfiginBun.build.Bun.buildtakescheck: true. A type error fails the build like any other build error, and nothing is written. The check uses theconditionsandloaderof the build.Compiler options as flags
Any compiler option works as a flag, like in
tsc. Flags overridetsconfig.json.bun check --noUncheckedIndexedAccess bun check --strict false bun check --target es2022Output
In a terminal:
When there are more than 50 errors, identical errors are grouped and sorted by how often they happen.
--allprints every one.When stdout isn't a terminal, you get the same format as
tsc --pretty false, so problem matchers and scripts written fortsckeep working.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.
With
AGENT=1:What you need installed
That's for
console,fetch,Bunandbun:test.bun initinstalls it for you.You don't need the
typescriptpackage. TypeScript'slib.*.d.tsfiles (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.Flags
-p,--project <path>tsconfig.json, or the directory that holds one--pretty/--no-pretty--all--threads <n>--timing--cwd <path>--strict,--target, ...tscDocs 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.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
tscnoEmit,declarationandincrementaldon't change anything.bun checkhas no--watch.bun --watch --check <file>checks before every restart.pluginsincompilerOptions) aren't loaded.listFiles,listFilesOnlyandtraceResolutionwork. Other options that only print things, likeexplainFiles, are ignored.npm i --save-devsaybun add -d.lib.*.d.tsfiles are built in. A path in one of them is printed asbundled:///libs/lib.dom.d.ts.Unknown compiler option 'strct'. Did you mean 'strict'?package.jsonisn't installed, a note names thepackage.jsonand says how to runbun installfor it.tsc, a note says how many files weren't checked:Stopped before type checking 2 files. Fix the errors above to see the rest.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.tsc.You can see it with 2 files:
With
allowJs,checkJsandstrict:ac--singleThreadedor--checkers 1a_user.jsrenamed tozz_user.jsbun check, either nameHow 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 checkagainst the realtscfrom 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
tsc7.0.2 and withbun check, once for everytsconfig.jsonin it. "Identical" means both report the same set of (file, line, column, error code).tsconfig.jsonfilestscbun checksrc/tsconfig.jsonThe 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
distfolders don't resolve. A project with no errors only shows thatbun checkraises 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,
tscreports 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 oftenbaseUrl, which TypeScript 7 removed, and checks no types at all.bun checkstops 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
tscnames the typestring | (object & { url?: string; })andbun checknames its alias.For vscode, next.js and elysia that's every
tsconfig.jsonin 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 withreferences(138 of them) are compared againsttsc -b, the rest againsttsc -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 -brepeats 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:
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.-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
.tssources and atsconfig.json, checked where they sit innode_modules:tscbun check<%= dasherize(name) %>@actions/github-script@arrows/array@arrows/composition@arrows/dispatch@arrows/error@arrows/multimethodatomically@aws-crypto/sha1-browser@aws-crypto/sha256-browser@aws-crypto/sha256-js@aws-crypto/util@azure/arm-appservice@azure/arm-resources@bcoe/v8-coveragebenny@braintree/sanitize-url@braintree/sanitize-urlcomment-parsercomment-parsercomment-parsercomment-parsercomment-parsercomment-parsercomment-parser@compodoc/ngd-core@compodoc/ngd-transformerconvexconvex-helperscsp_evaluatordevcertdexie-react-hookseditions@electric-sql/pglite-socket@electric-sql/pglite-socket@electric-sql/pglite-socket@electric-sql/pglite-tools@electric-sql/pglite-tools@embroider/reverse-exportsexample-typescriptexpo-assetexpo-assetexpo-assetexpo-constantsexpo-constantsexpo-constantsexpo-file-systemexpo-file-systemexpo-file-systemexpo-fontexpo-fontexpo-fontexpo-hapticsexpo-imageexpo-keep-awakeexpo-keep-awakeexpo-keep-awakeexpo-linkingexpo-modules-autolinkingexpo-modules-autolinkingexpo-modules-autolinkingexpo-modules-coreexpo-modules-coreexpo-modules-coreexpo-splash-screenexpo-sqliteexpo-status-barexpo-status-barexpo-symbolsexpo-system-uiexpo-web-browser@expo/devcert@expo/devcert@expo/dom-webview@expo/log-boxfeed@glimmer/component@huggingface/jinjahuman-idhuman-idhuman-idhuman-idimport-in-the-middleimport-in-the-middleimport-path-rewritejunit-xmlkhroma@layerup/layerup-security@lokalise/node-apimerge-anythingmimetext@monaco-editor/reactmongodbmongodbmongodbmongodbmoo-colornext-mdx-remote-clientnpx-importoblivious-setonigasmopenapi3-tsopencontrolpath-data-parserpickleparserpinopinopinopinopiscinapiscinapkg-pr-newpkg-pr-newpkg-pr-newpkg-pr-newpkg-pr-newpkg-pr-new@pnpm/config.env-replace@pnpm/network.ca-fileprotractorreact-static-example-typescript@redocly/openapi-coreresolve-package-pathresolve-package-pathresolve-package-pathrxjsrxjsrxjssignal-polyfill@stablelib/base64@statelyai/inspect@streamparser/json@supabase/ssrtemplatetunnel-ratunfurl.jsuri-js@vercel/agent-eval-playground@verdaccio/core@verdaccio/logger-prettify@verdaccio/url@verdaccio/utilsvite-plugin-externalize-depsvscode-tas-client@wdio/xvfbwebpodwithworkbox-coreworkbox-core@workflow/worldyaml-ast-parserWe developed against most of the repos in the first table, so try it on your own project:
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.
tscbun checkstrictoffskipLibCheckoffcheckJsdeclarationisolatedModuleswithverbatimModuleSyntaxisolatedDeclarationserasableSyntaxOnlywith legacy decoratorsnodenext"types": []"lib": ["es5"]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 checkhas to produce the same files, byte for byte..errors.txt.types.symbols.js.trace.jsontraceResolutionNot every test has every kind of baseline. The
.errors.txtbaselines contain 966 different error codes.A
.jsbaseline also has the JavaScript thattscemits. That part isn't compared, because Bun has its own transpiler. The same goes for the.js.mapand.sourcemap.txtbaselines.bun checkdoesn'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 bysync.tsfrom a typescript-go tag). All 5 kinds run in CI:bun bd test test/cli/check/conformanceGenerated programs
TypeScript's tests only have a few forms of some things. Take this:
tscreports TS7022 oncur. Its type needs the type ofs, which needs what the loop assigns at the bottom, which needscur. 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
tscandbun checkon it, and compares. There's no expected output stored anywhere.tscis the oracle.These are in
test/cli/check/differential.test.ts, and all of them pass:string & {}, what they're indexed by, how a name is written in a destructuring or an object literaltscprintstscprintstscprints.d.tsthatskipLibCheckhides: 13 forms of base type, 9 kinds of declaration, 36 usestscprintstscprintsinfertscprintstscprintsexport =: what it exports, whatdeclare moduleadds to it, how it's imported, 3 messagestscprintstscprintstscprintstscprintsthis: 9 types, 28 usestscprintstscprintstsc -bprints, in orderIt also generates small projects, for file names that differ only in case, like
import "./Button"forbutton.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 withforceConsistentCasingInFileNameson, off and unset.files), in which spellings, in which order, from wheretscprintsButton.tsandbutton.tstscprintstsc -bprintstsconfig.jsonwith a value that isn't JSON, like"strict": tru: 28 kinds of value in 30 placestscprintstsconfig.jsonwhose root is a list, like[{ "compilerOptions": {} }]tscprintsreferencesand 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 missingtsc -bprints, in orderimport()andrequire()withimportorrequiresomewhere in their text, which changes whattscsays about why a file is in the programtscprintsThe index signature and overload programs end with a line that has a type error, and
tschas to report it. After a syntax error neither of them checks anything, and then they'd agree about everything. CI runs 70 of the 840tsconfig.jsonfiles, 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.jsoninstalls next to 6 astypescript7. You can also point it at anothertsc:TSC=/path/to/typescript-7/tsc bun bd test test/cli/check/differential.test.tsThese run locally against
tscand aren't in the repo yet:tscsuper, references to itself, 2 members with one nametsccreates lazilyThe 1 in module augmentation is the wording of a message. In 28 of the messages that are cut off,
tscdoesn'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,tscreports an error andbun checkreports 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.
tsccrashes with a stack overflow on 23 more of those programs, which aren't counted.bun checkreports an error in all 23.Is it overfit to the tests?
esModuleInterop,noUncheckedIndexedAccess,definePropertyand so on).internal/checkerare named in comments, so you can read the two side by side.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
tscagainst its own regressions, andtscnever 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 checkandtscprinted something different. Each was marked by how likely it is to come up:tscnowThe marks are a reader's judgement, not a measurement.
By what a user would see:
tscnowtscdoesn't reportThe crash no longer crashes. It still prints one line more than
tsc.641 of the 652 are in
test/cli/check/differential-cases.json, anddifferential.test.tsrunstscandbun checkon 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/effectfrom Effect (496 files, and about as hard on a type checker as TypeScript gets), breaks 4 files at a time, runstscandbun check, and compares every error with its whole message. There are 32 ways to break a file: swap 2 arguments, drop a type argument, turnneverintounknown, delete an overload, reverse a conditional type, removereadonly, and so on.tscbun checkbun checkModule resolution
With
traceResolution,tsclogs every step of resolving every import: each file it looks for, eachpackage.jsonit reads, each condition inexportsit tries.bun checkprints 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.jsonsays "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.jsonfiles 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
ifstatements in afinallyblock, 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.tsc7.0.2 takes 53.Everything else
Adding a type checker shouldn't make
bun runorbun buildslower when you aren't type checking. Parsing 7,012 TypeScript files (37 MB) takes 1.003x the instructions it does onmain, andtypescript.js(9 MB) takes 0.995x.Performance
tsc7.0.2 with default settings againstbun checkon 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:
tsc7.0.2bun checksrcpackages/nextscriptspackages/reactCPU time:
tsc7.0.2bun checksrcpackages/nextscriptspackages/reactPeak memory:
tsc7.0.2bun checksrcpackages/nextscriptspackages/reactThe 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
tsc6.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, thenbun check --skipLibCheck false, which also checks every declaration file in@types/bun,@types/nodeand React against the built-inlib.*.d.tsfiles:bun init -ybun init --reactbun init --react=tailwindbun init --react=shadcnProjects that have an older
typescriptinstalled.bun checkreports the same errors as it does with the files of the TypeScript 7.0.2 package:typescriptA test compares all 108 built-in files with the
typescriptpackage, byte for byte.Also compared with
tsc7.0.2, with the same output: 150 valid, outdated, misspelled, empty and wrongly typed values intsconfig.json. Windows line endings, a byte order mark, UTF-16 and emoji in source files. Paths with spaces, Cyrillic, Japanese and accents. Atsconfig.jsonthat extends 1 package, 2 packages or a missing one. Both kinds of decorators. The config files thatcreate vitewrites. A workspace whose packages import each other's sources.bun check | head -1with 24,000 errors exits normally. Ctrl-C in a terminal ends it in 35 ms with exit code 130.What hasn't been tested
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.unsafecode doesn't run under Miri.Known problems
Where it differs from
tsc7.0.2: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.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.strictoff: 7 of typescript-go's 74 errors are missing, and there are 5 that it doesn't report.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.bun checksays it couldn't finish the file and exits with code 1.type A = string | boolean, the type ofc ? [1] : xis printed asnumber[] | Awheretscprintsstring | 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.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.