loading...
Cover image for Flow 0.99: Callable Properties, Function Statics, and More

Flow 0.99: Callable Properties, Function Statics, and More

wgao19 profile image Wei Gao ・6 min read

Cover image: Text of Time #9

Flow released 0.99 (comparing changes) a few days ago. This release contains changes around callable properties, function statics, a few more utilities coming from the React Component realm, bugfixes, and performance improvements for Flow server.

Once again I'm using those bits and bites to understand more about Flow and JavaScript, and this time it'll be mostly around callable properties and function statics, which I'll share about in this post.

🍾 Function statics

Here is one change that caused new errors in our codebase:

Make function type statics stricter

Before this change, we optimistically typed the statics of a function type as any, under the assumption that a function would not be used as an object. In practice, our optimism here is unfounded.

This diff changes the type of the statics object for function types to an empty, inexact object type with Function.prototype for its proto. This choice is sound with respect to the runtime behaviors while also allowing functions with statics to be valid subtypes via width subtyping.

Time for a review on JavaScript. What are function statics?

For a JavaScript class, a static method is a method for the class, not instances of that class.

For a JavaScript function, statics refer to those weird moments where you may have a variable that can be accessed by saying function name dot that variable name because functions are also objects in JavaScript πŸ€·πŸ»β€β™€οΈ. Consider this greeting function with a counter:

function greeting(name) {
  if (!greeting.counter) {
    greeting.counter = 0
  }
  greeting.counter++
  return 'hello, ' + name
}

So what this change says is that previously Flow did not put any restrictions on function statics, i.e., they were typed to any. In the example above, this would mean that greeting.counter is a field of any, and is therefore unrestricted.

You may type function statics, for example:

function greeting(name) {
  (greeting.counter: number)
  if (!greeting.counter) {
    greeting.counter = 0;
  }
  greeting.counter++;
  return 'hello, ' + name;
}

Play around in the Try Flow and note that if you change the annotation of counter to string, Flow is catching the error:

4:   (greeting.counter: string)
      ^ Cannot cast `greeting.counter` to string because number [1] is incompatible with string [2].
References:
6:     greeting.counter = 0;
                          ^ [1]
4:   (greeting.counter: string)
                        ^ [2]

🐿 New error in our codebase

The real error that happened in our codebase, however, is a bit different.

We write our Redux actions with data wrapped around in a field conventionally referred to as payload. And we normally annotate payload like this:

type ActionPayload = {
  data: string,
  error: string,
}

Sometimes payload can be a function that returns an object containing the data, so we're happy to see this too:

const fetchData = ({
  type,
  payload,
}: {
  type: 'FETCH_DATA',
  payload: () => ActionPayload,
}) => {
  // function body
}

In a few occasions, though, we mistyped the payload in the function form, where the payload is actually just the object. So our code worked fine and no one noticed that the annotation isn't making sense at all:

const fetchData = ({
  type,
  payload,
}: {
  type: 'FETCH_DATA',
  payload: () => ActionPayload, // <- wrooooong, or not?
}) => {
  return payload.data
}

At 0.99, as function statics are now typed to {}, Flow is complaining that we cannot get data because data is missing in statics of function type.

A similar scenario is a mixture of actions and action creators. They get passed around quite often, which makes typing even hairier. I realize that Flow really is getting in our way when we try to be smart in this direction. I like the flexibility but the complaints will keep asking me whether this is a good pattern or not, which I do not yet have an answer.

πŸ‘Ύ Callable property syntax

Remove deprecated $call property syntax

First of all, to not be confused, this is not the $call utility type, which helps you get the return type of a given function, and is not deprecated nor removed.

The deprecated $call property syntax was the precursor of this callable syntax, both the addition of the new syntax and the deprecation of the old happened in 0.75.

So what are they about, anyway?

It ties back to the fact we mentioned earlier that with JavaScript functions are also objects. And here's an interesting insight from Flow:

An object with a callable property can be equivalently viewed as a function with static fields.

And so it turns out:

// You should be able to call objects with call properties
function a(f: { (): string }, g: { (x: number): string }): string {
  return f() + g(123)
}

// ...and get an error if the return type is wrong
function b(f: { (): string }): number {
  return f()
}

And similarly:

// You should be able to use an object as a function
function a(x: { (z: number): string }): (z: number) => string {
  return x
}

Most amazing #TIL for me is perhaps this:

// Multiple call properties should also be supported
function a(f: { (): string, (x: number): string }): string {
  return f() + f(123)
}

// It should be fine when a function satisfies them all
var b: { (): string, (x: number): string } = function(x?: number): string {
  return 'hi'
}

And here is another one that reminds us about functions, that those monadic chained calls made possible by prototypes come down to not much more than a field in a function:

// Expecting properties that do exist should be fine
var b: { apply: Function } = function() {}

So that's a really interesting feature that Flow possesses! That means you can annotate memoized functions with Flow and I never even thought of that before learning all this. Consider a memoized factorial (Try Flow) and you can annotate it expressively with Flow:

type MemoizedFactorialType = {
  cache: {
    [number]: number,
  },
  [[call]](number): number,
}

const factorial: MemoizedFactorialType = n => {
  if (!factorial.cache) {
    factorial.cache = {}
  }
  if (factorial.cache[n] !== undefined) {
    return factorial.cache[n]
  }
  factorial.cache[n] = n === 0 ? 1 : n * factorial(n - 1)
  return factorial.cache[n]
}

There are a few more changes around callable properties in this version, which I'll list out their references:

And here is a couple more links to some older commits you may want to look at regarding callable properties:

πŸ¦– Other notable changes

There are few more notable changes I'll just list here for now:

If you find any of those interesting and have learned something about them, please please write about them because ever since 0.85 my learning about Flow has felt like sitting in a class where no one asks any questions but apparently not many people understand what's going on πŸ€¦πŸ»β€β™€οΈ. Secretly I'm also not completely certain about things I said about Flow. If you spot any mistakes, please do let me know.

πŸ’― Till next time

Flow 0.100 is released too. If you haven't noticed, the release notes now come with Try Flow examples. Maybe the added digit indicates a new era.

Discussion

pic
Editor guide
Collapse
yawaramin profile image
Yawar Amin

Hey thanks for the detailed explanation. A bit off-topic but, I noticed that you're modelling ActionPayload as a type alias rather than an interface. Do you have a philosophy of which to prefer, and when?

Collapse
wgao19 profile image
Wei Gao Author

Hey, Yawar, thank you for bringing this up! It just so happened that I'm recently stuck with this because we changed a shared type from interface to type alias and it broke the function calls that previously relied on the common interface. And so I'm still trying to figure out.

I also realize that things like like TOrGeneratesT (the ActionPayload in my post) do not work very well (Try Flow). I don't know how I can use interface here to help though, any pointers?

Collapse
yawaramin profile image
Yawar Amin

Hey in this case it seems a disjoint union would work, e.g.

type PureTOrGenT<T> =
  | {tag: 'PureT', value: T}
  | {tag: 'GenT', value: () => T}

function logMyCorn<Unicorn>(unicorn: PureTOrGenT<Unicorn>) {
  switch (unicorn.tag) {
    case 'PureT':
      console.log(unicorn.value)
      break
    case 'GenT':
      console.log(unicorn.value())
  }
}

The tag allows Flow to refine the type of value down to a T or exact function type in each branch.

My rule of thumb is to stick to interfaces unless I need the added power of unions, intersections, exact types etc. However I'm not sure what could be causing the first error you mentioned, because interfaces and inexact object types are equivalent afaik. I'll need to investigate further.

Thread Thread
wgao19 profile image
Wei Gao Author

Oh I see. And I find this approach more readable too. Can I ask -- where do you feel the boundary between flexibility and type safety lies?

For the other issue, check out this Try Flow. It works when Common is an interface. If you change Common to a type then Flow reports cannot call the second function with the parameter.

In our code, the use case is we have multiple variants of a similar object type from different APIs. And we have a lot of utility functions that rely on a few common fields of either all or some of the variants. So we're now trying to understand what's the best practice behind interface, sealed vs unsealed, exact vs inexact objects..

On a side note, seems that Flow is moving towards exact object by default. Wondering whether we're gonna make Flow very unhappy again πŸ˜…

Thread Thread
yawaramin profile image
Yawar Amin

It's an iterative process for me. While I'm working on a specific area I try to type everything on the 'mainline' of what I'm doing, and tell myself to come back and type out the 'side jobs' later. For example, I've recently been working with JSON validation for models, so I created a type that is essentially type Decoder<A> = Object => Promise<A>, and a constructor for this type function decoder<A>(jsonSchema: JsonSchema): Decoder<A>. For now I have type JsonSchema = Object, but I know that when I have some time I'll circle back and put in a definition that should only allow legal JSON Schemas.

Regarding the issue in Try Flow, I may be missing something, but it's not quite working like that. Even when interface Common, I'm seeing an error on this line: obj.missingProperty && console.log(obj.missingProperty); // error

The error is:

    17:   obj.missingProperty && console.log(obj.missingProperty);  // error
          ^ all branches are incompatible: Either property `missingProperty` is missing in `Common` [1]. Or property `missingProperty` is missing in object type [2].
        References:
        6: type HasExtra = Common & {
                           ^ [1]
        6: type HasExtra = Common & {
                                    ^ [2]

That looks about right, missingProperty is not mentioned in any of the type definitions so I would not expect it to work. When that is commented out though I am surprised that this works: takesExtra(obj) because we know obj: Common but takesExtra takes obj: HasExtra. Effectively Flow is having to downcast a supertype to a subtype to report no errors?!

Wow, the planned Flow inexact/exact type switch is going to be a big one. I've asked on that post, but I have a feeling that interfaces will not be affected, i.e. they will continue to be 'inexact'. My tendency is to use interfaces as much as possible; sometimes, there's just no point in giving a sub-object type a name so I directly use an object type {foo: bar}. I'm not sure I understand the post's argument that this being an inexact type makes it unsafe. I know there are some cases where you just don't want to allow passing in extra properties but IMHO it would be more annoying than unsafe. I have some examples in a post I wrote, dev.to/yawaramin/interfaces-for-sc...

Thread Thread
wgao19 profile image
Wei Gao Author

Oops, sorry I think my annotation on the previous flow try was confusing.

The previous link works as expected, missingProperty is expecting error because it is not mentioned, and c is OK because it is annotated as optional in HasExtra. However, once we change interface to type, while both c and missingProperty still work as expected, there is an error on line 12 that does not let us pass the common object.

I will definitely need to read your post on interface. Thanks for sharing! Nobody in our team has any experience with it and Flow's doc doesn't say much about it neither πŸ˜…

Thread Thread
yawaramin profile image
Yawar Amin

Oh I see what you mean now–and why the interface type works! Because an object with type:

interface Common {
  a: number,
  b: string,
}

... can always be safely downcast to its subtype type HasExtra = Common & {c?: string}. In fact now I'm surprised the type version doesn't work, ha ha. In any case, yeah I recommend to looking at interfaces more heavily. The doc page doesn't mention this but they can be used to model the shapes of not just classes but also 'POJOs', just like in TypeScript. This gives them quite a wide variety of use cases.

Thread Thread
wgao19 profile image
Wei Gao Author

So, it turns out that (from my understanding) interfaces allow structural subtyping which distincts themselves from types.

Although, I run into this error that I don't quite understand, but looks interesting..

Thread Thread
yawaramin profile image
Yawar Amin

Wow, yeah that's a head-scratcher. No idea what's happening there!