Nomadic cattle rustler and inventor of the electric lasso.
Company Website
Follow me on twitter
Contact me for frontend answers.

typescript - excess property checks and return type widening

March 03, 2019

The title of this post would not have meant anything to me 24 hours ago until I came across a scenario that I am surprised I have not come across before while I was trying to tighten up the types in react-move.

react-move is a perfect accomplyment to d3 and is a great library for performing animations on SVG elements.

react-move comes with a NodeGroup component that takes an array of data and returns a set of nodes.

Suppose we have an array of Points:

export type Point = { x: number, y: number }

const points: Point[] = [{ x: 0, y: 0 }, { x: 1, y: 2 }, { x: 2, y: 3 }]

This data can be passed as a prop to the NodeGroup along with some lifecycle methods start, enter, update and leave that get called by react-move:

<NodeGroup
  data={points}
  keyAccessor={d => d.id}
  start={() => ({ top: 350, left: 20 })}
  enter={d => ({ top: [d.x], left: [d.y] })}
  update={d => ({ top: [d.x], left: [d.y] })}
  leave={d => ({ top: [d.x], left: [d.y] })}
></NodeGroup>

A function is supplied for each of the lifecycle methods which will be called by react-move for every element in the array. The supplied function needs to return some configuration of what should be animated. In the example above, I am returning an object with top and left properties that I want to animate a component moving.

I want to add better safety around what is returned from the lifecycle events. If the user returns {tp: 1, left: 'bbo', excess: 3} instead of {top: [1], left: [2]} then I want typescript to start yelling.

With this in mind, I came up with these types to map this:

export type AddArrayLike<T> = {
  [P in keyof T]: T[P] | T[P][]
}

export interface INodeGroupProps<T = unknown[], State = {}> {
  data: T[];
  keyAccessor: (data: T, index: number) => string | number;
  interpolation?: GetInterpolator;
  start: (data: T, index: number) =>  State;
  enter?: (data: T, index: number) => AddArrayLike<State>;
  update?: (data: T, index: number) =>  AddArrayLike<State>;
  leave?: (data: T, index: number) => AddArrayLike<State>;
  children: (nodes: T & { key: string | number, data: T, state: State }[]) => React.ReactElement<any>;
}

export declare class INodeGroup<T = unknown[], State = {}> extends React.Component<INodeGroupProps<T, TConfig, State>> { }

I want to give the user the option of passing in a State type argument which will give type safety about what can be returned from each of the lifecycle events.

This appeared to work well and works well with this example below:

export interface NodesState {
  top: number;
  left: number;
  opacity: number;
}

const Nodes: INodeGroupProps<Point, NodesState> = {
  data: points,
  keyAccessor: d => {
    return d.x
  },
  start: point => {
    return {
      top: point.y,
      left: point.x,
      opacity: 0,
    }
  },
  enter: point => {
    return {
      top: [point.y],
      left: [point.x],
      opacity: [0],
    }
  },
  update: point => {
    // etc.
  },
  leave: point => {
    // etc.
  },
}

Excess Properties

While testing this out, I was able to change any of the properties of the NodesState interface to not be a number type and I got a nice type error, e.g.

return {
  top: [point.y],
  left: ['ahhh'], // Type 'string[]' is not assignable to type 'number[]'.
  opacity: [0],
}

or if I remove a prop, I get a nice type error:

enter: point => { // Property 'top' is missing in type '{ left: number[]; opacity: number[]; }'
  return {
    left: [point.x],
    opacity: [0],
  }

Just one more check to make and all will be good and I’m done. I added this property that does not exist on the NodesState interface that is the return type of the function:

enter: point => {
  return {
    top: [point.y],
    left: [point.x],
    opacity: [0],
    fook: 'blah blah'  // no fooking type error for excess property
  }

I was very surprised. This is not very good.

It turns out that excess property checking is not performed in the body of a function expression.

The excess property in this case is my fook property. So I’ve covered off the excess property part of the cryptic blog post title but what is return type widening?

Type widening

I can reproduce the problem I found with a much, much simpler example that shows the problem:

interface Foo {
  x: () => { x: 'hello' };
}

const a: Foo = {
  x: () => {
    return {
      x: 'hello',
      excess: 3, // no error
    }
  },
}

In the case above, the x function is typed independently as part of determining the type of the outer object literal and it’s return type is determined to be:

  { x: 'hello', excess: 3 }

Then this function is checked for compatability with the x in the foo interface and is found to be compatible. At no point was a direct assignment of the object literal to an explicit typed reference checked.

The only way to fix this is to explicitly type the return type of the function:

type X = { x: string }

interface Foo {
  x: () => X;
}

const a: Foo = {
  x: (): X => {
    return {
      x: 'hello',
      excess: 3, // Object literal may only specify known properties, and 'excess' does not exist in type'X'.
    }
  },
}

This is less than ideal and not a huge help for my problem.

Excess property checks don’t kick in unless you directly assign a value to a reference for a specified type. So unless you directly assign the object to a parameter or variable or return that are explicitly typed, you won’t get an error.

There are a number of issues in the typescript github repo about this being the first known issue raised.

Most of the time excess type checking does take place and requires an exact type, but because this is the return value of a callback function, return type widening prevents excess checking from taking place.

You can read more about type widening here.

I would also love to be wrong about this and if anyone can point out a fix, please do so in the comments below.


Paul Cowan

Nomadic cattle rustler and inventor of the electric lasso.
Company Website
Follow me on twitter
Contact me for frontend answers.