Control flow analysis of aliased conditional expressions in TypeScript

An interesting feature was added to TypeScript recently that will improve the ergonomics of code that relies on type narrowing or discrimination:

TS 4.4 can infer when a variable's type implies something about the type of another.

A simple example given in the PR description:

function fn(x: unknown) {
    const isString = typeof x === 'string'
    if (isString) {
        x.length  // Ok
    }
}

Even though we know that if (and only if) isString is true, x must be a string, the type checker doesn't know that!

This is because isString is just a stupid old boolean - it doesn't know or care why it happens to be true or false.

For TS to understand the expression implies something about its inputs, typeof x === 'string' has to be inlined inside the if statement (or ternary if you're that way inclined).

function fn(x: unknown) {
    if (typeof x === 'string') {
        x.length  // Ok
    }
}

This is pretty annoying because we can no longer rearrange our code as we see fit.

We have to choose: do we structure our program to appease the cold, emotionless type checker, or appease nice and cuddly humans using lovely abstractions like names and expression reuse?

We can have our cake and eat it by pulling out the expression into a type guard predicate, but that's a lot of boilerplate and even bug prone - if our guard predicate and function body fall out of sync, we have an invisible type-checker-defeating bug on our hands!

function brokenIsStr(x: unknown): x is string {
  return typeof x !== 'string'
}

That's a very dense and dry cake!

At this point TS is looking less like "just JS with types" and more like a verbose subset that's hard to read and write.

This has changed in TS 4.4, as isString is now imbued with the implication our brains associate with it - TS understands that iff isString is true, x must be a string.

This means we can start decoupling our conditionals from the expressions they depend on; our TS programs start looking a bit more nimble, our cake a little moister!

Limitations

Variables don't encode a history of their every logical implication - it's not magic.

foo's type can only imply something about bar when foo is const and either:

  1. the result of a conditional expression about bar in the current scope (i.e. foo is a boolean)
  2. a discriminant property of bar (i.e. bar is a discriminated union)

It supports up to 5 levels of indirection before giving up:

function fn(x: unknown) {
    const isString = typeof x === 'string'

    const twoLevelsDeep = isString || isString
    const threeLevelsDeep = twoLevelsDeep || isString
    const fourLevelsDeep = threeLevelsDeep || isString
    const fiveLevelsDeep = fourLevelsDeep || isString
    const sixLevelsDeep = fiveLevelsDeep || isString

    const justOneLevelDeep = isString || isString || isString || isString || isString || isString

    if(fiveLevelsDeep) {
        x // string
    }
    if(sixLevelsDeep) {
        x // unknown
    }
    if(justOneLevelDeep) {
        x // string
    }
}

and as of yet it doesn't fold away identical expressions.

Whilst an aliased conditional expression on a destructured field will allow for narrowing the original object's type, the flow analysis cannot narrow the type of a destructured sibling.

This coincidentally makes destructuring arguments inside the function signature less useful to the type checker - you may be better off destructuring arguments on the next line.

As an example, a predicate upon foo cannot influence the inferred type of bar here:

function fn({ foo, bar }: Baz) {
  ...

But it can influence the type of baz:

function fn(baz: Baz) {
  const { foo, bar } = baz
  ...

This might change in the future, but it's something to bear in mind.

Another important limitation is that narrowing a specific property of an object (as opposed to narrowing the type of the object overall) requires that property to be readonly, potentially tipping the balance in favour of readonly properties by default.
Despite going out of its way to support mutability, the more advanced TypeScript's analysis gets, the more it encourages functional programming with immutability.

Downsides

There's inevitably some implicit complexity introduced - we'll have to take care to remember when a seemingly innocent boolean is being relied upon by the type checker elsewhere.

Any kind of inference increases coupling between disparate parts of our program - a change over here is more likely to change something over there.
This is a trade off we make all the time; to avoid it entirely requires redundantly and tediously enunciating every single type in your program.

Anyone stuck working with an older version of TS will also have to be slightly more careful when blindly copy pasting from the internet - the weaker inference may render copied code incompatible.

A Practical Example

Let's build a slightly contrived e-commerce website with React - how hard could it be?

Our customers will go through several steps - browsing the catalogue, selecting shipping, then confirming and paying for their order.

Let's represent those steps as React component state using a discriminated union... something like:

type ShoppingStep = {
  step: "shopping"
  discountCode?: string
  loggedIn: boolean
}
type SelectShippingStep = Omit<ShoppingStep, "step"> & {
  step: "select-shipping"
  items: Array<Item>
}
type ConfirmOrderStep = Omit<SelectShippingStep, "step"> & {
  step: "confirm-order"
  shippingAddress: Address
}

export function OnlineShop(): JSX.Element {
  const [state, setState] = useState<
    ShoppingStep | SelectShippingStep | ConfirmOrderStep
  >({
    step: "shopping",
    loggedIn: false,
  })

  ...
}

With each step represented as a separate component:

function Catalogue(props: ShoppingStep): JSX.Element

function ShippingSelect(props: SelectShippingStep): JSX.Element

function ConfirmOrder(
  props: ConfirmOrderStep & {
    freeShipping: boolean;
    children?: ReactNode
  },
): JSX.Element

Now let's put it all together by picking the component depending on the step and calculating free shipping eligibility:

const shippingMessage =
    "shippingAddress" in state &&
    checkFreeShippingEligibility(
      state.items,
      state.shippingAddress
    )
      ? `Congrats! Free shipping on ${state.items.length} items!`
      : undefined

  switch (state.step) {
    case "shopping":
      return <Catalogue {...state} />
    case "select-shipping":
      return <ShippingSelect {...state} />
    case "confirm-order":
      return (
        <ConfirmOrder
          {...state}
          freeShipping={
            "shippingAddress" in state &&
            checkFreeShippingEligibility(
              state.items,
              state.shippingAddress
            )
          }
        >
          {shippingMessage ?? "Now pay up!"}
        </ConfirmOrder>
      )
  }

This works, but our shipping message logic is pretty dense, and our free shipping check is duplicated!

Can we do better?

Let's split apart the shipping message logic and reuse the free shipping check:

const freeShipping =
    "shippingAddress" in state &&
    checkFreeShippingEligibility(
      state.items,
      state.shippingAddress
    )

  const shippingMessage =
    freeShipping
      ? `Congrats! Free shipping on ${state.items.length} items!`
      : undefined

  ...

    case "confirm-order":
      return (
        <ConfirmOrder {...state} freeShipping={freeShipping}>
          {shippingMessage ?? "Now pay up!"}
        </ConfirmOrder>
      )

Much better! But this line:

? `Congrats! Free shipping on ${state.items.length} items!`

actually fails the type checker in TS 4.3.4 due to state.items not necessarily being present: here's proof.

The fix is to duplicate the shipping address check:

const shippingMessage =
    "shippingAddress" in state && freeShipping
      ? `Congrats! Free shipping on ${state.items.length} items!`
      : undefined

and now we're paying the price just to satisfy the type checker.

Let's take advantage of the enhanced inference introduced in TS 4.4 to not only deduplicate, but further tidy up our code!

const hasShippingAddress = "shippingAddress" in state

  // `hasShippingAddress` conditional alias
  // allows state to be narrowed to ConfirmOrderStep
  // so `items` and `shippingAddress` are known to be present
  const freeShipping =
    hasShippingAddress &&
    checkFreeShippingEligibility(
      state.items,
      state.shippingAddress
    )

  // state is again narrowed to ConfirmOrderStep because
  // `freeShipping` is an aliased conditional twice removed!
  const shippingMessage = freeShipping
    ? `Congrats! Free shipping on ${state.items.length} items!`
    : undefined

  const {step} = state

  // switching on an (aliased) destructured discriminant property
  switch (step) {
    ...
    case "confirm-order":
      return (
        <ConfirmOrder {...state} freeShipping={freeShipping}>
          {shippingMessage ?? "Now pay up!"}
        </ConfirmOrder>
      )
  }

Here's the full code in 4.4 as compared to the same in 4.3.4.

This is loads better - we've got (slightly more) destructuring, lots of named variables and naturally narrowed types, without duplicating type guard expressions.

Conclusion

TS 4.4's flow analysis of aliased conditional expressions starts to deliver - to stretch an analogy - a type checked, moist and light, more JavaScript-y cake.

Our TS code can start looking a bit more like the flexible, human-friendly programs we're used to; we're telling the machine what to do, not the other way around!

Included in the 4.4 release notes is another write-up of the new feature - I recommend giving the whole thing a read as there are a bunch of juicy new features waiting to be tried out!

21