Getting started
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.
The rows TypeScript wins
The ecosystem. A package on npm ships .d.ts. Flow reads
library definitions, and a package that has
none is any until somebody writes one. flow-typed covers a fraction of what
DefinitelyTyped covers. If your application stands on twenty typed
dependencies, this is the bill, and it does not get smaller by choosing uf.
Editors. uf ships uf lsp, and every editor integration under
editors/ is a client
of it. What it advertises in initialize is the whole of what it can do:
diagnostics, textDocument/formatting, textDocument/codeAction
(quickfix and source.fixAll.uf) and textDocument/hover. There is no
rename, no go-to-definition, no find-references, no auto-import.
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 — and a dependency that does not 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 has no Flow types. A TypeScript project gets
import.meta.glob, import.meta.hot, import.meta.env and the ambient
declarations for *.svg, ?raw and ?url from vite/client. There is no Flow
equivalent, from Vite or from uf, so a five-line file using them reports five
type errors — issue #264 has
the file and the output.
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 about Flow. They are uf's, they have issue numbers, and what uf does not do keeps the rest of the list.
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 declare
#![feature(box_patterns)], and that feature was removed from the compiler
around the 2026-09-01 nightly, so rust-toolchain.toml pins the whole workspace
to nightly-2026-08-01. 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, 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 and where only some of the packages have reached npm
(issue #210).
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.