Readiness: Implemented
Why uf
Flow and TypeScript
You already write TypeScript. Everyone arriving here does, and pretending the comparison is close would be the fastest way to lose your attention. TypeScript wins most of the rows. Flow wins four that only exist because it is the type system React itself is written in — and one fact about Flow's implementation is the reason a toolchain like uf can be built at all.
What you will be able to do: weigh Flow against TypeScript row by row — the ecosystem, editors, and the rows uf itself loses — and know which Flow constructs have no TypeScript spelling.
What you need first: TypeScript you already write. Why Flow is the shorter argument this page tests.
The rows TypeScript wins
The ecosystem. A package on npm ships .d.ts, and Flow reads
library definitions. uf translates a
package's declarations, its own or its @types package's, into Flow, so the
package is typed rather than any. A translation is still not the declarations:
where TypeScript says something Flow has no way to say, the result is any,
and Flow reads some of the rest less precisely than TypeScript does. If your
application stands on twenty typed dependencies, this is still the bill. It is
smaller than it was, and uf counts it per package instead of hiding it.
Editors. uf ships uf lsp, and every editor integration under
editors/ is a client
of it. What it can do is the diagnostics it pushes — the linter's when a document
opens or changes, and Flow's type errors, the ones uf check reports, once
typing pauses — and what it advertises in initialize: textDocument/formatting,
textDocument/codeAction (quickfix, source.fixAll.uf and source.organizeImports),
textDocument/hover, textDocument/definition, textDocument/typeDefinition,
textDocument/completion, textDocument/references, textDocument/documentHighlight,
textDocument/rename, textDocument/documentSymbol, textDocument/signatureHelp,
workspace/symbol, textDocument/selectionRange, textDocument/linkedEditingRange
and textDocument/foldingRange. Hover, the definitions and completion outside
uf.config.js are answered by Flow's own inference over the whole project, kept
in memory: the type under the cursor, where a name or a type is declared — across
files and into node_modules — the members of a value after ., and a name
exported by another file of the project, with the relative import that brings it
in. A package under node_modules, an export *, and a name bound by
destructuring are outside that import. A type error whose rewrite Flow can make
inside the file is a quickfix. A fix that needs another file's signature, or a
package, is not, and source.fixAll stays the linter's. source.organizeImports
sorts the file's imports the way Flow does, and is not part of that fix-all.
There are no inlay hints. Against TypeScript 7's own server, which advertises
those and a broader auto-import, that is still most of the gap.
tests/library/lsp.test.js drives the real binary and asserts both the
capabilities the READMEs claim and the absence of the ones they disclaim.
People. More engineers have written TypeScript, more answers exist, and the
odds that your next contributor has seen renders before are low.
uf does not argue with any of that.
The rows uf loses, which is a different list
The list above is Flow's. This one is uf's, and it is the more useful one, because it is the part that can be fixed and the part that will bite you this week.
There is a library definition for everything, and Flow has fewer. A
TypeScript project gets @types/* for practically any package that ships no
types of its own, and a node: builtin is described down to its overloads.
Flow's own library definitions cover the DOM, the standard library and React,
and stop well short of that. uf check resolves a dependency that opts into
Flow with @flow: it reads the package's manifest out of node_modules,
follows its exports map and types your code against the package's real
declarations. A dependency that ships TypeScript declarations instead is typed
from a translation of them, which the next section measures. A dependency with
neither is Flow's unchecked module: the import is any, and the report names
it rather than leaving you to find out.
types checked 31
asked about 8 of 31
inference 196.7ms
builtins 26.6ms (cold)
› these imports are typed as any; uf resolved no module for them
- @uniflowed/host/module-mocks
- node:async_hooks
- node:module
That is uf init with its dependencies installed: eight files of
the project's own, and twenty-three modules read out of node_modules to type
them against. What is left in the list is one file that says @noflow on
purpose and two node: builtins Flow does not declare. Under TypeScript that
list would be empty, and that is the row.
Vite's client API is declared, and one guard in it is not a refinement. A
TypeScript project gets import.meta.glob, import.meta.hot and the ambient
declarations for asset imports from vite/client, and uf ships the Flow
declarations for the same API, so import.meta.glob("./*.js") and
import logo from "./logo.png" check without a libdef of your own. What Flow
cannot do is refine a test on import.meta: the if (import.meta.hot) guard
Vite's own documentation writes is an error under uf check, and uf's lint,
vite/hot-needs-optional-chaining, asks for import.meta.hot?.accept()
instead. An extension a project adds to Vite's assetsInclude is not read, so
an import of that kind is still any.
A module with no import and no export is read as a script. That is how
uf decides which of ECMAScript's two goal symbols a file is written in, and the
difference it makes is await: a module may await at its top level, a script
may not, because in a script await is still an ordinary identifier. A file
that awaits and neither imports nor exports is refused, and an export {} is
enough to say what it is.
Issue #204.
None of those three is an argument against Flow's model of React. They are what using it through uf costs today, and what uf does not do keeps the costs that are still open, each with its issue number.
A package that ships .d.ts
Most of npm is written in TypeScript and publishes declaration files, and
uf check reads them. For a dependency with no Flow of its own, it looks for
the declaration file TypeScript would read for the import. That is the types
condition of an exports map, then types or typings and typesVersions,
then @types/<name> when the package ships none. uf translates that file, and
every declaration file it reaches, into Flow, and checks your code against the
result. A second check reads the translation back from .uf/cache/dts instead
of making it again.
What carries over. The checker uf embeds reads most of TypeScript's type language, so most of it is written as it was:
- interfaces and type aliases, with optional and
readonlymembers and index signatures - unions, intersections and literal types
- tuples with labels, optional elements and rests of tuples
- overloads, and generics with bounds and defaults
keyof, conditional types withinfer, mapped types and template literal types- type guards, classes, enums, namespaces and
export = - the utility types, under their TypeScript names
Where the two languages mean different things, the translation writes what
TypeScript meant. An object type becomes an inexact interface, a property is read
covariantly, a type parameter gets the variance TypeScript measures, unknown
becomes mixed and never becomes empty.
What does not. A construct Flow has no way to say becomes a hole: any,
named by the declaration it is in. These constructs become holes:
declare global, and adeclare module "x"augmentation- an assertion signature
- an array rest inside a tuple
- a computed key other than
Symbol.iteratorandSymbol.asyncIterator intrinsic, and a JSDoc typetypeof f<T>, andimport x = N.yexport =orexport defaultof something that is not a name, and a default-exported type- a relative import of a file the package does not ship
- an enum member whose value is not a constant, or an enum mixing strings and numbers
- two declarations of one name that Flow cannot bind together
- a
staticindex signature, or one keyed by anything butstring,numberorsymbol - a class that extends an expression
globalThis
What Flow reads differently. Some declarations that do carry over are ones Flow types less precisely than TypeScript: a conditional type it does not reduce, an array spread into a tuple, a member its own library definitions lack. Flow reports an error inside the translation for each, and a use that reaches one can be typed more loosely than TypeScript would type it. The report counts those errors beside the holes.
Here are the numbers for the three packages #946 was filed about. A file of misuses of them used to check clean, and now has one error per misuse:
| Package | Declaration files | Holes | Flow errors inside the translation |
|---|---|---|---|
zod@4.6.5 | 95 | 17 | 224 |
date-fns@4.4.0 | 258 | 1 | 0 |
@tanstack/query-core@5.102.8 | 12 | 5 | 0 |
The footer of every uf check gives the same two numbers for each package it
translated. uf check --explain-any <package> lists every one of those places,
with the declaration file, the line, the declaration and the reason. Here is the
start of zod's list, cut at the ellipses:
› zod: 17 holes typed as any, 224 Flow errors inside its translation
- node_modules/zod/v4/classic/compat.d.cts:29 BRAND [computed-key] a computed key other than `Symbol.iterator` or `Symbol.asyncIterator` has no Flow spelling, so the member is left out
- node_modules/zod/v4/classic/schemas.d.cts:554 discriminatedUnion [array-rest-in-tuple] Flow spreads only a tuple into a tuple, so a tuple type with an array rest is `any`
…
- node_modules/zod/v4/core/core.d.cts:12 $constructor.init [assertion-signature] Flow has no assertion signatures, so this returns `void` and narrows nothing after a call
…
- node_modules/zod/v4/classic/schemas.d.cts:85 ZodType.apply [incompatible-type] Readonly<[]> [1] is incompatible with Array<unknown> [2].
…
What Flow says that TypeScript has no spelling for
Props are the parameter list
component Avatar(src: string, size: number = 32, alt?: string) {
return <img src={src} width={size} height={size} alt={alt ?? ""} />;
}
The TypeScript that does the same job is three declarations of one thing:
type AvatarProps = { src: string; size?: number; alt?: string };
function Avatar({ src, size = 32, alt }: AvatarProps) {
return <img src={src} width={size} height={size} alt={alt ?? ""} />;
}
An interface, a destructuring pattern and a defaults list: three places that
describe one prop and can drift apart. The default is the clearest case —
size?: number says a caller may omit it, and says nothing about the 32 they
get when they do. Flow's
component syntax has one
place to write a prop, so there is nothing to keep in step.
It also means the React Compiler is told this is a component instead of inferring it from a capital letter — see Flow, the modern parts for what uf hands it.
The rules of hooks are a type error
hook useNow(interval: number): Date { … }
Flow refuses to let that be called from anything but a component or another
hook. TypeScript's answer to the same problem is eslint-plugin-react-hooks: a
lint rule, in a second tool, with a second configuration, enforcing something
the type checker has no opinion about.
Flow's hook syntax makes it the
same checker that checks everything else.
A component can constrain its children
component Tab(label: string) renders React.Node { … }
component Tabs(children: renders* Tab) { … }
renders Tab says the component's output eventually renders a Tab; renders?
allows nothing instead, and renders* allows any number.
TypeScript's nearest expression is
children: ReactElement<ComponentProps<typeof Tab>>, which checks the props
of the element — so a different component that happens to take a label: string
satisfies it, and a wrapper around Tab does not. Flow checks the render chain,
which is the thing a design system actually means.
Render types.
Exhaustiveness that is a language feature
enum Status { Idle, Loading, Failed }
const label = match (status) {
Status.Idle => "Ready",
Status.Loading => "Working…",
Status.Failed => "Failed",
};
match reports match-not-exhaustive when a
case is missing and flags patterns that can never run, and
Flow enums are real runtime values with
cast, isValid, members and getName — a union of string literals is none
of those, and TypeScript's enum is a different construct with its own
history. uf lowers both where it parses them, with no import added to your
module; Flow, the modern parts has the output.
Why uf could be built at all
This is the part written nowhere else, and it is the actual reason this project exists rather than a wish for better React types.
Flow's parser and type checker have an official Rust port, and uf links it.
upstream/flow is a git submodule pinned to a commit;
upstream/flow/rust_port/crates/flow_parser is the parser Flow itself runs, and
uf_check embeds the typing crates beside it. There is no second backend and no
feature flag selecting one, because a build of uf that spoke a different dialect
would make uf lint and uf check disagree with the grammar this site
documents.
So the dev server, the production build, the test runner, the formatter and the linter read one syntax tree produced by Meta's own code. That is not an integration uf is proud of having assembled — it is the reason there is nothing to assemble.
What it costs, in the same paragraph. 23 crates in the port match through
Box with deref_patterns, which is still unstable. The compiler removed
box_patterns around the 2026-09-01 nightly, and
tools/upstream/patches/flow/0018-deref-patterns.patch is the replacement, so
rust-toolchain.toml pins the whole workspace to nightly-2026-10-07. No
stable Rust toolchain can build uf. The
Upstream Flow CI job builds the parser on the floating channel as an early
warning, so the pin moves deliberately rather than being discovered when it
breaks. Every number and every embedding hazard is in
docs/architecture.md.
How to decide
Stay on TypeScript if your application leans on many typed dependencies
whose holes you cannot live with (uf check --explain-any lists them), if your
team is hiring, or if anything on the list of rows uf loses is load-bearing for
you this quarter. That is most people, and it is a reasonable answer.
Try uf if you are writing React rather than JavaScript-with-React — if the
things you want the type system to catch are a missing prop, a hook called from
the wrong place, a child component that does not belong, and a match you
forgot to extend — and if you can work in pre-release software whose interfaces
move without warning.
The syntax argument in full is Why Flow. The boundaries are What uf does not do. If you want the head-to-head against the tool you are actually using today, that is uf compared.
Where to go next
uf compared is next: uf against Next.js, Vite, Bun and
create-react-app, with the rows it loses in the same tables. What uf does not
do is the full list of refusals and gaps.
Edit this pagedocs/app/guide/typescript/$page.mdx