Can mapStateToProps narrow the types of props?

Viewed 121

I'm attempting to add type annotations to an old component, and am not sure if connect's types support what i'm trying to do. The component is a connected component which takes in a prop with a broad type, then uses that prop to look up a value in state, and spits out a narrower prop.

At the moment, i can define the props with the broad type and the code will work. However, any code i write inside the component has to conform to this broad type, even though i know it has been narrowed. Thus to satisfy typescript i either have to add in type assertions, or unnecessary typechecks.

Here's a simplified example of the code to show the behavior:

interface ExampleProps {
  thing: string | number;
}

const Example extends React.Component<ExampleProps> {
  render() {
    const problem = this.props.thing * 2; // type error

    // Workaround, but losing some typesafety
    // const notAProblem = (this.props.thing as number) * 2;

    // Workaround, but doing unnecessary checks
    // if (typeof this.props.thing === 'number') {
    //   const notAProblem = this.props.thing * 2;
    // }

    return null;
  }
}

const mapStateToProps = (state: RootState, otherProps: ExampleProps) => {
  if (typeof otherProps.thing === 'string') {
    return {
      thing: state.lookup[otherProps.thing]; //resolves to a number
    }
  }
  return {
    thing, // also a number
  }
}

export default connect(mapStateToProps)(Example)

In the above code, typescript rightly points out that const problem: number = this.props.thing * 2; is a problem. With the types i've defined, this.props.thing might be a string, so it correctly gives the error The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.

Is it possible to use connect in such a way that different props come in than go out? In other words, i need someone to be able to render <Example thing={2} /> or <Example thing={"2"} />, and yet for code inside the Example to know that, thanks to mapStateToProps, thing can only be a number

1 Answers

Approach 1: Generic type per-field

One approach would be to a make "thing" a generic type on ExampleProps, i.e.:

interface ExampleProps<T extends (string | number)> {
  thing: T;
}

Then you could just specify different concrete types in the class and function type signatures respectively:

class Example extends Component<ExampleProps<number>> {
  render() {
    const problem: number = this.props.thing * 2;
    return null;
  }
}

const mapRootStateToExampleProps = (state: RootState, {thing}: ExampleProps<string | number>) => {
  if (typeof thing === 'string') {
    return {
      thing: state.lookup[thing] //resolves to a number
    }
  }
  return {
    thing, // also a number
  }
}

This may be not be an optimal solution, though, as your type signatures could get verbose quite fast. It only really makes sense on one or two fields of the ExampleProps interface.

Approach 2: Base interface for "out" params, "in" params extend base

An alternative could be to declare a base interface representing the props coming in, and then an interface which extends it representing the props going out - then you only have to specify the types which get modified when "going out", so you're not repeating a lot of code:

interface ExamplePropsIn {
  thing: string | number;
  other: string;
}

interface ExamplePropsOut extends ExamplePropsIn {
    thing; number;
}

class Example extends Component<ExamplePropsOut> {
  render() {
    const problem: number = this.props.thing * 2;
    return null;
  }
}

const mapRootStateToExampleProps = (state: RootState, {thing}: ExamplePropsIn) => {
  if (typeof thing === 'string') {
    return {
      thing: state.lookup[thing] //resolves to a number
    }
  }
  return {
    thing, // also a number
  }
}
Related