FACEBOOK FLOW: WHAT VERSION IS IT?
Ladies and gentlemen, I present to you Facebook Flow - a type checker that's been in development for 11 years and is currently on version... *checks notes*... 0.294.0.
ZERO. POINT. TWO-NINETY-FOUR.
After 11 years and nearly 20,000 commits, they still haven't hit 1.0.
Meanwhile TypeScript is on version 5.9 and has 198 TIMES more weekly downloads. 112.8 million vs 567K. That's not competition. That's a rounding error.
The first commit? October 30, 2014:
4982063649 2014-10-30 Initial commit -- add README
The commit history is a cemetery of abandoned dreams and documentation rewrites. I counted 15 commits just on November 14, 2014 with messages like:
- "Update 00-getting-started.md"
- "Update 00-getting-started.md"
- "Update 00-getting-started.md"
They couldn't even get the README right in one try.
This is a type checker that lost. Let me show you WHY it lost.
BIT #1: THE UNSOUNDNESS MODULE
src/typing/type.ml:3949-3993:
module Unsoundness = struct
let constructor = Unsound Constructor
let merged = Unsound Merged
let instance_of_refi = Unsound InstanceOfRefinement
let unresolved = Unsound UnresolvedType
let resolve_spread = Unsound ResolveSpread
let unimplemented = Unsound Unimplemented
let inference_hooks = Unsound InferenceHooks
let exports = Unsound Exports
let bound_fn_this = Unsound BoundFunctionThis
let dummy_static = Unsound DummyStatic
...
end
They have an ENTIRE MODULE called Unsoundness.
Not a single function. Not a utility. A MODULE. With 12 DIFFERENT FLAVORS of unsoundness:
- BoundFunctionThis
- Constructor
- DummyStatic
- Exports
- InferenceHooks
- InstanceOfRefinement
- Merged
- ResolveSpread
- Unchecked
- Unimplemented
- UnresolvedType
- NonBindingPattern
This is a type checker that has CATEGORIZED its failure modes. They didn't just accept unsoundness - they TAXONOMIZED it. They gave their bugs a LINNAEAN CLASSIFICATION SYSTEM.
Most type checkers try to be sound. Flow created a Pokemon collection of the ways it ISN'T.
"Gotta catch 'em all" - Flow team, probably
BIT #2: "WE ARE INTENTIONALLY BEING UNSOUND HERE"
src/typing/react_kit.ml:603-606:
(* NOTE: We are intentionally being unsound here. If config is inexact
* and some props are present then technically there might be other props
* to spread (we just don't know which props). However, the "safe" option
* would be to assume that the type of key is mixed. Instead we are unsound
Read that comment again. SLOWLY.
"We are INTENTIONALLY being UNSOUND."
This isn't a bug. This isn't technical debt. This is a DESIGN DECISION. Someone CHOSE this. Someone looked at the options and said "you know what? Let's just lie to our users."
And look at the reasoning:
- The "safe" option would work
- But they chose not to do it
- Because... reasons
This is a type checker admitting, IN ITS OWN SOURCE CODE, that it will deliberately give you wrong information.
But wait, there's more! Check out src/typing/type.ml:1363:
from an indexer, where our current semantics are intentionally unsound with
It's not just one place. They're intentionally unsound in MULTIPLE places. The lies are a FEATURE.
BIT #3: THE _TODO VARIABLE
src/typing/default_resolve.ml:11-12:
let rec default_resolve_touts ~flow ?resolve_callee cx loc u =
let _TODO = () in
git blame shows:
74745f24867 (Mike Vitousek 2022-08-16 02:03:44 -0700 12) let _TODO = () in
Mike Vitousek, on August 16, 2022, wrote a variable called _TODO that equals the UNIT TYPE. That's literally (). NOTHING.
What does this accomplish? Let me show you how it's used:
| ResolveSpreadsToMultiflowCallFull _ -> _TODO
| ResolveSpreadsToMultiflowSubtypeFull _ -> _TODO
| Lower _ -> _TODO
| UseT _ -> _TODO
Instead of implementing the actual logic, they just return NOTHING and call it a TODO. This variable is literally a placeholder for "we didn't finish this."
It's been sitting there for over 3 years. That's not a TODO. That's a WONTDO. That's a NEVERGONNADO.
Mike, buddy - if you're reading this - just delete the function at this point. The code has rotted past its expiration date.
BIT #4: EVIL LAUGH IN PRODUCTION CODE
src/monitor/flowServerMonitor.ml:212-213:
(* Wait forever! Mwhahahahahaha *)
Lwt.wait () |> fst
git blame:
9bc56ac3b10 (Gabe Levi 2018-03-23 18:31:08 -0700 212) (* Wait forever! Mwhahahahahaha *)
Gabe Levi, on March 23, 2018, wrote an evil laugh into production code.
This is ACTUAL CODE that runs on ACTUAL DEVELOPER MACHINES, managing the Flow server monitor, with a comment that sounds like a Saturday morning cartoon villain.
But here's the thing - the code LITERALLY DOES wait forever. This isn't an exaggeration. It's not a joke. It's a thread that blocks indefinitely.
Lwt.wait () creates a thread that will NEVER resolve. They're not waiting for something. They're waiting for NOTHING. FOREVER.
The evil laugh is accurate documentation.
This has been in the codebase for 7 years. Seven years of Flow servers doing their best Dr. Evil impression on every developer's machine.
BIT #5: "WOW, THIS IS A SHADY WAY TO RETURN NO RESULT!"
The following code snippet demonstrates a questionable approach to returning no result:
src/server/command_handler/commandHandler.ml:848:
(* TODO: wow, this is a shady way to return no result! *)
let tys =
if json then
ServerProt.Response.InferType.JSON Hh_json.JSON_Null
else
ServerProt.Response.InferType.Friendly None
According to git blame:
7deddb6c616 (Panagiotis Vekris 2023-04-27 17:44:04 -0700 848) (* TODO: wow, this is a shady way to return no result! *)
Panagiotis Vekris, a senior engineer, looked at this code in April 2023 and his professional assessment was: "wow, this is a shady way to return no result!"
The code returns JSON_Null when it has nothing to return. Instead of, you know, returning NOTHING. Or an error. Or literally anything other than a lie.
The developer KNOWS it's shady. They ADMIT it's shady. They wrote a TODO about how shady it is. And then they SHIPPED IT ANYWAY.
This is in the command handler. This is the code that responds to your IDE when you hover over a variable to see its type. When your IDE shows you "null" for a type, it might just be Flow being... shady.
BIT #6: A TYPE CHECKER FULL OF "PROBABLY"
Throughout the codebase, the developers express deep uncertainty:
src/monitor/flowServerMonitorServer.ml:349-351:
(* TODO (glevi) - We probably don't need to make the monitor exit when the file watcher dies.
* We could probably just restart it. For dfind, we'd also need to start a new server, but for
* watchman we probably could just start a new watchman daemon and use the clockspec *)
THREE "probably"s in ONE comment. This isn't engineering. This is GUESSING.
More gems:
(* TODO: with_types should probably be false, but this maintains previous behavior *)
Translation: "We don't know if this is right but we're scared to change it"
(* Uncaught exn. We probably could survive this, but it's a little risky *)
Translation: "The server MIGHT crash. Who knows? Not us!"
(* NOTE: Havoc-ing the state when entering the handler is probably
overkill and could be replaced with... *)
Translation: "This is probably wrong but we're too tired to fix it"
This is a TYPE CHECKER. Its literal job is to REMOVE uncertainty from code. And yet its own source code is riddled with the word "probably."
The blind leading the blind, except the blind are also uncertain about their blindness.
BIT #7: $TEMPORARY$ TYPES: THE PERMANENCE OF "TEMPORARY"
Flow has these special types:
- $TEMPORARY$object
- $TEMPORARY$array
- $TEMPORARY$number
- $TEMPORARY$string
They were so "temporary" that they had to build an ENTIRE CODEMOD just to remove them:
packages/flow-upgrade/src/codemods/replaceTemporaryTypes.js:17-23:
title: 'Replace $TEMPORARY$ types',
description: [
' - `$TEMPORARY$object<{props}>` to `$ReadOnly<{props}>`',
' - `$TEMPORARY$array<T>` to `$ReadOnlyArray<T>`',
' - `$TEMPORARY$number<42>` annotations to `number`',
' - `$TEMPORARY$string<"foo">` annotations to `string`',
They literally have types in their type system called TEMPORARY that became so permanent they needed automated tooling to migrate away from them.
This is like naming a variable deleteThisLaterXXX and then promoting it to a published API that thousands of developers depend on.
The commit history tells the tale:
4c9b64f8fe 2024-10-09 Panos Vekris [flow] checker does not recognize $TEMPORARY$ types
0f6cf2ef8a 2024-10-09 Panos Vekris [flow] Include codemods to remove temporary types
October 2024. These "temporary" types lived for YEARS before they were finally dealt with.
Nothing is as permanent as a temporary solution.
BIT #426: 426 TODOs AND COUNTING
I ran a grep on the OCaml source code:
$ grep -r "TODO\|FIXME\|HACK\|XXX" --include="*.ml" | wc -l
426
426 unfinished pieces of work. In 315,000 lines of OCaml.
Some highlights:
(* TODO - find a way to gracefully kill the workers *)
Eleven years and no graceful shutdown.
(* TODO: This should be a parse error, but we only produce an internal *)
They know it's wrong. They just... don't fix it.
(* TODO send error to client *)
There are MULTIPLE places where errors just... don't get sent to the client.
(* TODO *)
Just... "TODO". No description. No context. No hope.
And my personal favorite:
(* TODO: we should fix the monitor to just handle this without exiting! *)
The monitor exits when it shouldn't. They know. They haven't fixed it.
These aren't new TODOs either. Some have been there for YEARS. They're not technical debt at this point - they're technical bankruptcy.
BIT #9: "FACEBOOK-SPECIFIC HACK"
src/parser_utils/type_sig/type_sig_options.ml:29:
(* NOTE: This is a Facebook-specific hack that makes the signature verifier and generator *)
src/parser_utils/type_sig/type_sig_options.ml:68:
(* NOTE: This is a Facebook-specific hack that makes the signature verifier and generator *)
TWICE in the same file, they admit to having Facebook-specific hacks baked into what's supposedly an open-source type checker.
But it gets better. Here's from the commit history:
4047e69f8a 2025-04-24 [flow][refactor] Localize the hacky fix cache for `Object.assign` checking
and document why it's bad
They have a "hacky fix cache" and they wrote documentation about "why it's bad." That's not fixing things. That's INSTITUTIONALIZING the hacks.
And check this revelation from the web search:
> "Vladan Djeric, engineering manager supporting the Flow team at Facebook, announced that Flow type checker will go beyond being just JavaScript with types and introduce new features based on Facebook's internal user needs."
The team's priority is "their internal Facebook customers." This is an open-source project that serves Facebook first and everyone else as an afterthought.
No wonder TypeScript won. TypeScript serves DEVELOPERS. Flow serves FACEBOOK.
BIT #10: THE 10,000 LINE FILE
Let's talk about src/typing/statement.ml - a file with nearly 10,000 lines of OCaml code.
$ wc -l src/typing/statement.ml
9948
For context, many coding guidelines recommend keeping files under 500 lines. This file is TWENTY TIMES that.
The comment at the top tells you everything:
(* This module contains the traversal functions which set up subtyping
constraints for every expression, statement, and declaration form in a
JavaScript AST; the subtyping constraints are themselves solved in module
Flow_js. *)
Translation: "This file does EVERYTHING."
And src/typing/flow_js.ml? 9,604 lines.
And src/analysis/env_builder/name_resolver.ml? 6,889 lines.
And src/analysis/env_builder/__tests__/env_builder_refinement_test.ml? 7,569 lines.
That's one TEST FILE with 7,569 lines. SEVEN THOUSAND lines of tests. For ONE component. Either the component is incredibly complex or the tests are incredibly verbose. Both options are bad.
This isn't a codebase. It's a monument to accumulated complexity that no one dares to refactor.
BIT #11: THE DEPRECATED PILE
From the changelog, Flow is constantly deprecating things:
* `React$RefSetter<...>` and `React$Context` are removed.
* `React$ElementRef<...>` was removed.
* The deprecated `React$Element` type is now removed.
* You should use `ExactReactElement_DEPRECATED` (yes, that's the actual name)
* `React.ElementProps` is removed.
They have a type LITERALLY CALLED ExactReactElement_DEPRECATED.
Look at the code:
"ExactReactElement_DEPRECATED"
You know what's worse than deprecated code? CODE WITH "DEPRECATED" IN ITS NAME that's STILL IN USE.
The changelog is a graveyard:
- Breaking change
- Likely to cause new Flow errors
- Breaking change
- Likely to cause new Flow errors
- Breaking change
Every single release. Constant churn. The community warned about this:
> "Flow alienated users by shipping broad, wide-sweeping breaking changes on a regular cadence. Maintaining a Flow application felt like being subject to Facebook's whims."
This isn't evolution. It's thrashing.
BIT #12: EVEN JEST LEFT
From GitHub issue #7365 and web searches, I found the ultimate betrayal:
> "Jest (another Facebook project) announced they were planning on migrating
> their codebase from Flow to TypeScript."
JEST. FACEBOOK'S OWN TESTING FRAMEWORK. Decided to MIGRATE AWAY from Flow.
That's like your own children disowning you. That's like your DOG preferring the neighbor. That's like being rejected by the one person who LITERALLY HAS TO love you.
Facebook made Flow. Facebook made Jest. Jest looked at Flow, looked at TypeScript, and said "yeah, we're out."
And Jest isn't alone. From the GitHub issue #7223 titled (I kid you not) "Give up and switch to TypeScript":
> "I realized it's a dead horse" - steida
> "Flow is considered harmful. That's sad but it's true." - steida
> "It was wasted time fighting with all those errors" - steida
> "One can so much hate only what he really loved" - peter-leonov
That last one... that's not a roast. That's a eulogy.
When even the people who LOVED your project are writing poetry about their disappointment, you know you've truly failed.
BIT #13: THE UNSAFE FUNCTION COLLECTION
Count the functions with "unsafe" in their name:
get_file_addr_unsafe
get_haste_module_unsafe
get_dependency_unsafe
read_ast_unsafe
read_docblock_unsafe
read_aloc_table_unsafe
read_type_sig_unsafe
read_tolerable_file_sig_unsafe
read_file_sig_unsafe
get_parse_unsafe
get_typed_parse_unsafe
get_package_parse_unsafe
get_resolved_requires_unsafe
get_resolved_modules_unsafe
get_leader_unsafe
get_ast_unsafe
get_aloc_table_unsafe
get_docblock_unsafe
get_exports_unsafe
get_imports_unsafe
get_tolerable_file_sig_unsafe
get_file_sig_unsafe
get_type_sig_unsafe
get_file_hash_unsafe
content_of_file_input_unsafe
apply_changes_unsafe
flow_t_unsafe
resolved_lower_flow_unsafe
resolved_lower_flow_t_unsafe
resolved_upper_flow_t_unsafe
get_method_type_unsafe
...
I stopped counting at 30. THIRTY FUNCTIONS with "unsafe" in the name.
And these aren't internal utilities. Look at parsing_heaps.ml:
val get_parse_unsafe
val get_typed_parse_unsafe
val get_package_parse_unsafe
val get_resolved_requires_unsafe
These are in a MODULE SIGNATURE. These are PUBLIC APIS. They're ADVERTISING that the functions are unsafe.
A type checker whose own API is named "unsafe" is like a seatbelt manufacturer selling "probably_works_belt."
BIT #14: "MAKE FLOW ACCEPT AND TRANSLATE SOME TS SYNTAX"
src/flow_dot_js.ml:993:
"desc": "Make Flow accept and translate some TS syntax automatically."
Flow now has an option to accept TypeScript syntax.
Let that sink in.
The type checker that was COMPETING with TypeScript... now has a feature to PARSE TypeScript syntax. They didn't just lose - they're trying to be COMPATIBLE with the winner.
This is like Betamax adding a feature to play VHS tapes. This is like Internet Explorer adding a "just use Chrome" button. This is like Zune having an iPod compatibility mode.
They've given up competing and are now trying to be... useful? By supporting the thing they were supposed to replace?
From the parser:
| TSSatisfies _
| TSAbstractClass
| TSClassVisibility of [ `Public | `Private | `Protected ]
They're parsing TypeScript visibility modifiers. TypeScript's public/private/protected system is now being parsed BY FLOW.
The surrender is complete. Flow hasn't just lost - it's become a TypeScript translator.
THE CLOSER: A TYPE SYSTEM'S EPITAPH
Let me paint you a picture.
It's 2014. Facebook releases Flow, a type checker that promises SOUND typing for JavaScript. They have PhD researchers. They have resources. They have the biggest JavaScript codebase on Earth to test against.
Eleven years later:
- Version 0.294.0 (still not 1.0)
- 315,000 lines of OCaml
- 426 TODOs
- 12 categorized types of UNSOUNDNESS
- 30+ functions named "unsafe"
- Comments like "Mwhahahahahaha" and "wow, this is shady"
- 214x fewer downloads than TypeScript
- Their own sibling project (Jest) migrated away
TypeScript won. But that's not even the story.
The story is HOW they lost. They lost by:
- Being intentionally unsound while claiming soundness
- Prioritizing Facebook's needs over the community
- Breaking changes every release
- Writing evil laughs into production code
- Creating $TEMPORARY$ types that lasted years
- Building an Unsoundness MODULE with 12 flavors of failure
The saddest part? Flow was RIGHT. Sound typing IS better. Inference IS powerful. They had the better idea.
But they executed it like they were the only developers who mattered. Because at Facebook, they were.
Here's the final nail. From the web search:
> "We are humans, not robots, and sympathy for developer suffering is important"
Flow caused developer suffering. Not from bad ideas - from bad execution, bad communication, and treating the open-source community as second-class citizens.
Rest in peace, Flow. You were the better type checker that lost because you forgot that types are for PEOPLE, not just for Facebook's CI pipeline.
P.S. - The README still proudly displays badges showing CircleCI, Twitter followers, and Discord. The Twitter hasn't been updated in years. The Discord is mostly people asking how to migrate to TypeScript.
Even the README is lying.