Home / Articles / Flow vs TypeScript in 2026: Syntax Overlap and Stricter Checks

This article is published in English.

Flow vs TypeScript in 2026: Syntax Overlap and Stricter Checks

Explore how Flow's syntax now mirrors TypeScript's, plus its unique match expressions, React-specific types, and runtime bugs TypeScript misses but Flow catches.

1178 words

By 2026, Flow has evolved along three notable lines:

  • Its syntax now largely overlaps with TypeScript's: developers who already know TypeScript will recognize most of what Flow does.
  • It offers certain capabilities that TypeScript still lacks: most notably match expressions and dedicated component, hook, and renders constructs for React.
  • When the two type systems disagree, Flow tends to pick the stricter option: it flags patterns that TypeScript permits but that can blow up at runtime, quietly corrupt data, or introduce subtle logic errors.

Flow and TypeScript syntax have converged

Take a look at the snippet below — can you tell whether it's Flow or TypeScript? You probably can't.

type User = {
  readonly name: string,
  readonly age: number,
  readonly metadata: unknown,
};

function get<K extends keyof User>(user: User, key: K): User[K] {
  return user[key];
}

declare const user: User;
const age: number = get(user, 'age');

Anyone comfortable with TypeScript will find nothing surprising here. The sample relies on keyof, readonly fields, the unknown type, indexed access via T[K], and bounded generics with extends. Flow also supports conditional types, mapped types, type guards, and as const, mirroring TypeScript's toolkit closely.

The remainder of this piece focuses on the areas where Flow pushes further than TypeScript does.

Flow-only features

Here are capabilities that exist in Flow but have no equivalent in TypeScript.

match expressions and statements

Flow provides a match construct for pattern matching as an expression. The compiler checks it exhaustively and lets you destructure values directly as part of the matching process. If you forget to handle a case — say, type: 'remove'match will point it out and tell you exactly what's missing.

type Action =
  | {type: 'add', text: string}
  | {type: 'toggle', id: string}
  | {type: 'remove', id: string};

declare const action: Action;

const description = match (action) { // ERROR: 'remove' case missing
  {type: 'add', const text} => `Add: ${text}`,
  {type: 'toggle', const id} => `Toggle ${id}`,
};

There's also a statement form of match, which behaves like a switch that can't fall through and carries all the same capabilities as the expression version.

React: component, hook, renders

  • component syntax: treats React components as a built-in language-level concept. Props are declared directly as named parameters, and the type checker can enforce React-specific correctness rules.
  • renders types: let you describe how components compose with one another. Design systems and component libraries can specify exactly what a slot is allowed to accept and what a component is allowed to produce, and the checker enforces those contracts even through wrapper components.
  • hook syntax: marks hooks as their own distinct kind, separate from ordinary functions. Flow bakes the Rules of React directly into its type checker, catching conditional hook calls, mixing up hooks with regular functions, and unsafe mutation of a hook's return value during render — all without needing a separate ESLint plugin.
component Header(text: string, color: string) {
  return <div style={{color}}>{text}</div>;
}
component MainHeader(text: string) renders Header {
  return <Header text={text} color="red" />;
}

component Layout(header: renders Header) {
  return <div>
    {header}
    <section>Content</section>
  </div>;
}

const ok = <Layout header={<MainHeader text="Flow" />} />;
const bad = <Layout header={<footer />} />; // ERROR

Four runtime crashes TypeScript doesn't catch — but Flow does

This is where the two type systems genuinely diverge. Every example below passes type-checking under TypeScript 6.0.3 with strict mode on, yet still fails at runtime.

  1. Pulling a method off an instance drops its this binding in TypeScript
class Counter {
  count: number = 0;
  increment(): number {
    return ++this.count;
  }
}
const counter = new Counter();
const tick = counter.increment;  // TS accepts. Flow rejects.
tick();  // Runtime crash! `this` is undefined inside `increment`

Once you take counter.increment off the counter object, it's no longer bound to it — so calling it later as tick() runs with this as undefined, and ++this.count throws. TypeScript treats the extracted method as an ordinary function and lets the call through without complaint. Flow, by contrast, rejects the extraction at the exact point where the this binding is lost.

  1. TypeScript allows extra properties to sneak in through indirect assignment
type Prices = {apple: number, banana: number};
const items = {apple: 1.5, banana: 0.5, sample: "free"};
const prices: Prices = items;  // TS accepts. Flow rejects.
Object.values(prices).map(
  price => price.toFixed(2), // Runtime crash! `sample` isn't a number
);

TypeScript's object types technically permit additional properties. The "excess property check" that would normally flag something like {apple: 1.5, sample: "free"} only kicks in when you assign an object literal directly. Route the same value through an intermediate variable, and that check no longer applies — so the stray sample field passes through unnoticed. Flow's object types are exact by default, meaning extra properties are rejected no matter how the value reaches its destination.

  1. TypeScript lets a broader type get pushed into a narrower mutable array
// TypeScript: accepted.
function appendError(errs: Array<string | Error>) {
  errs.push(new Error("oops"));
}
const errors: Array<string> = [];
appendError(errors);  // TS accepts. Flow rejects.
errors[0].toUpperCase();  // Runtime crash! `errors[0]` isn't a string

Because TypeScript treats mutable arrays as covariant, it considers Array<string> a subtype of Array<string | Error>. That means the call is accepted, and the push call inside appendError ends up inserting an Error into what the caller believed was a string-only array. Flow avoids this by treating mutable arrays as invariant, blocking the widening right at the call site. If the function has no real need to mutate its input, switching the parameter to ReadonlyArray<string | Error> removes the risk entirely, since immutability makes the widening harmless.

  1. TypeScript doesn't verify what a type guard's body actually does
// TypeScript: accepted, but this body is true for numbers, not strings.
function isString(x: unknown): x is string {
  return typeof x === "number";  // TS accepts. Flow rejects.
}
const data: unknown = 1;
if (isString(data)) {
  data.toUpperCase();  // Runtime crash! `data` isn't a string
}

TypeScript only checks the declared signature of a type predicate — it never inspects what the function body actually returns. Flow checks both directions: every return statement must genuinely narrow to the guard's target type, and the else branch must correctly rule that type out. As a result, Flow rejects predicates whose logic doesn't match what they claim to check.

Read the full comparison

For a broader set of examples, Flow's own documentation site hosts an extensive writeup comparing the two languages point by point, covering more than twenty additional cases beyond what's shown here: see the comparison guide.