Hafiz writes:

> almost every enterprise Java codebase I've had to work with has observed this pattern of testing whether an object is an instance of particular subclass, and using that to drive further computation. Even in the presence of such tests, Java demands that the author cast their objects appropriately. Of course you can always introduce an intermediate variable after the test, but I argue that this is still too much—upon verifying the instance of an object, the type system should be smart enough to update the object's type appropriately!

Because Java considers null to inhabit every type, in a Java project a few years ago, I handled this in a dynamic_cast-like way, as follows (abbreviated):

public abstract class Security { // ...
public Stock asStock() { return null; }
public Future asFuture() { return null; }
}

public class Stock extends Security { // ...
public Stock asStock() { return this; }
}

public class Future extends Security {
public final SecurityTable factory;
public final String exchange, symbol; // ...
public Future asFuture() { return this; }
}

This allows you to write relatively uncluttered type-dispatching downcasting code like this:

public boolean contains(Security sec) {
Future f = sec.asFuture();

return f != null
&& SecurityTable.this == f.factory
&& f.symbol.equals(symbol);
}

Of course this violates the open-closed principle (the abstract base class should be closed for modification) and official OO doctrine is that if you want different behavior for different subclasses you should put that behavior into a method that gets overridden by the subclasses, not in a conditional that attempts a downcast, so we did that a lot more often. But I found it a pleasant solution to the problem in the context of this Java project.

Of course it doesn't help in languages without implicit nullability, like TypeScript, which would ideally be all statically-typed languages.

— ⁂ —

The big difficulty with flow typing is, as I see it, not that it clashes with nominal typing; it's that it incorporates your compiler's static control flow analysis capabilities into your language's type system. Consider Hafiz's Java example:

if (node instanceof DomNode.Element) {
Layout layout = ((DomNode.Element) node).layout;
return new RenderNode.Styled(layout, /* ... */);
}
return new RenderNode.Noop(/* ... */);

It is entirely reasonable to request that the type system handle this and allow you to write:

if (node instanceof DomNode.Element) {
Layout layout = node.layout;
return new RenderNode.Styled(layout, /* ... */);
}
return new RenderNode.Noop(/* ... */);

But how about this? Now we're no longer inside the static extent of the `if`, and we're depending on the compiler to recognize the unconditional early return:

if (!(node instanceof DomNode.Element)) {
return new RenderNode.Noop(/* ... */);
}
Layout layout = node.layout;
return new RenderNode.Styled(layout, /* ... */);

How about constant folding and partial evaluation?

if (1 == 0 || node instanceof DomNode.Element) {
Layout layout = node.layout;
return new RenderNode.Styled(layout, /* ... */);
}
return new RenderNode.Noop(/* ... */);

Do we want to skip type checking entirely for dead code? Straightforwardly flow typing gives us that the type of every variable inside unreachable code is void, the uninhabited type, so any operation whatsoever on it is type-valid. Do we want the compiler to accept code like this?

if (1 == 0) {
Layout layout = node.layout + node / node;
return new RenderNode.Styled(layout, /* ... */);
}
return new RenderNode.Noop(/* ... */);

And of course doing control-flow analysis precisely isn't feasible due to the halting problem; you need to do some conservative approximation.

So, if you want your programs to be portable from one version of the compiler to the next, somewhere you need to write down precisely what conservative approximation you're using for control-flow analysis, and in particular what you're not.