TypeScript 4.4: Aliased Conditions and Flow Analysis

Felipe Hlibco

TypeScript 4.4 shipped last week, and the feature I keep reaching for isn’t the one I expected.

I figured the new strictness flags would dominate my attention. They didn’t. Instead, it’s the control flow analysis of aliased conditions that’s quietly changing how I write type guards. The feature is simple enough to explain in a paragraph, but the downstream effects on code organization are larger than the changelog suggests.

The Problem Before 4.4 #

Here’s a pattern every TypeScript developer has written:

function processValue(x: string | number) {
  if (typeof x === 'string') {
    // x is narrowed to string here
    console.log(x.toUpperCase());
  }
}

That works. TypeScript’s flow analysis narrows x to string inside the if block. No complaints.

But what if you want to extract that check into a variable? Maybe you’re reusing it, or maybe the condition is complex enough that naming it improves readability:

function processValue(x: string | number) {
  const isString = typeof x === 'string';
  if (isString) {
    // Before 4.4: x is still string | number here
    // TypeScript lost the connection between isString and x
    console.log(x.toUpperCase()); // Error!
  }
}

Before 4.4, that second example was a type error. The compiler couldn’t track that isString being true meant x was a string. The narrowing information got severed when you assigned the guard to a variable.

This forced a choice: write readable code with named conditions and add type assertions, or inline everything and let the compiler do its job. Neither option was great.

What Changed #

TypeScript 4.4 now tracks the relationship between a const variable and the type guard that initialized it. When you check isString, the compiler follows the alias back to the original condition and narrows accordingly.

function processValue(x: string | number) {
  const isString = typeof x === 'string';
  if (isString) {
    // 4.4: x is narrowed to string
    console.log(x.toUpperCase()); // Works!
  }
}

The key constraint is const. It has to be const, not let. Makes sense — if the variable could be reassigned, the compiler can’t trust that its value still reflects the original guard at the point of the if check.

This also works with discriminated unions, which is where it gets genuinely useful:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(shape: Shape) {
  const isCircle = shape.kind === 'circle';
  if (isCircle) {
    // shape is narrowed to { kind: 'circle'; radius: number }
    return Math.PI * shape.radius ** 2;
  }
  // shape is narrowed to { kind: 'square'; side: number }
  return shape.side ** 2;
}

I’ve already refactored a few validation functions that had deeply nested if chains. Extracting each check into a named const and then composing them with && reads much better — and now the compiler follows along.

Symbol and Template String Index Signatures #

The other headline feature is expanded index signatures. Before 4.4, index signatures could only use string or number as key types. Now you can use symbol and template string patterns.

Template string index signatures are the interesting one:

interface CSSProperties {
  [property: `--${string}`]: string;
}

const styles: CSSProperties = {
  '--primary-color': '#007bff',
  '--font-size': '16px',
};

That --${string} pattern lets you model CSS custom properties, environment variables prefixed with APP_, or any key that follows a naming convention. Before this, you’d either use a plain string index (losing the pattern information) or manually enumerate every key.

Symbol index signatures are more niche but they fill a real gap. Libraries that use well-known symbols as property keys (think iterators, Symbol.toPrimitive) can now type those patterns properly.

New Strictness Flags #

Two new flags worth knowing about.

--useUnknownInCatchVariables changes catch clause variables from any to unknown:

try {
  // ...
} catch (err) {
  // Before: err is any (you can do anything with it, no checking)
  // With flag: err is unknown (you must narrow before using)
  if (err instanceof Error) {
    console.log(err.message);
  }
}

I’ve been wanting this for a long time. The fact that catch variables defaulted to any was always a type safety hole. You’d call err.message and TypeScript would happily let you, even if the thrown value was a string or a number or undefined. With this flag, you’re forced to check first. It’s annoying for about a day and then you start catching actual bugs.

--exactOptionalPropertyTypes distinguishes between a missing property and a property explicitly set to undefined:

interface Config {
  debug?: boolean;
}

const a: Config = {};           // OK: debug is missing
const b: Config = { debug: undefined }; // Error with this flag!

Without the flag, both are valid. With it, setting debug explicitly to undefined is different from not including it. This matters for APIs that use Object.keys() or hasOwnProperty checks, where the presence of a key (even with an undefined value) changes behavior.

Honestly, this flag feels like it should’ve been the default from the start. The conflation of “missing” and “explicitly undefined” has caused so many subtle bugs in serialization code and API boundaries that I’ve lost count.

The Bigger Picture #

What I like about this release is that the marquee features are about code quality, not just type system expressiveness. Aliased conditions let you write cleaner, more readable type guards without sacrificing narrowing. The new strictness flags close genuine safety gaps that experienced TypeScript developers have been working around for years.

TypeScript releases sometimes feel like they’re aimed at library authors and type-level gymnasts (conditional types, template literal types, variadic tuples). This one feels aimed at application developers who just want their everyday code to be safer and more readable.

That’s a good direction. I’ll take “my named variable works with flow analysis” over another mapped type feature any day.

The upgrade path is smooth, too. None of the new features are breaking unless you opt into the new flags. And if you do opt in, the compiler errors you’ll get are almost certainly pointing at real issues in your code. That’s the best kind of strictness: the kind that pays for itself immediately.