How can I make a React "If" component that acts like a real "if" in Typescript?

Viewed 1894

I made a simple <If /> function component using React:

import React, { ReactElement } from "react";

interface Props {
    condition: boolean;
    comment?: any;
}

export function If(props: React.PropsWithChildren<Props>): ReactElement | null {
    if (props.condition) {
        return <>{props.children}</>;
    }

    return null;
}

It lets me write a cleaner code, such as:

render() {

    ...
    <If condition={truthy}>
       presnet if truthy
    </If>
    ...

In most cases, it works good, But when I want to check for example if a given variable is not defined and then pass it as property, it becomes an issue. I'll give an example:

Let's say I have a component called <Animal /> which has the following Props:

interface AnimalProps {
  animal: Animal;
}

and now I have another component which renders the following DOM:

const animal: Animal | undefined = ...;

return (
  <If condition={animal !== undefined} comment="if animal is defined, then present it">
    <Animal animal={animal} /> // <-- Error! expected Animal, but got Animal | undefined
  </If>
);

As I commented, althought in fact animal is not defined, I've got no way of telling Typescript that I already checked it. Assertion of animal! would work, but that's not what I'm looking for.

Any ideas?

3 Answers

It seems impossible.

Reason: if we change If component's content from

if (props.condition) {
  ...
}

to it's opposite

if (!props.condition) {
  ...
}

You will find out that this time you want the type diff been processed in the opposite way.

  <If condition={animal === undefined} comment="if animal is defined, then present it">
    <Animal animal={animal} /> // <-- Error! expected Animal, but got Animal | undefined
  </If>

Which means it's not independent, leading to the conclusion that it's not possible to make such type diff in this condition, without touching either of the two components.


I'm not sure what's the best approach, but here is one of my thought.

You could define Animal component's props animal with the typescript's
Distributive conditional types: NonNullable.

Document

type T34 = NonNullable<string | number | undefined>;  // string | number

Usage

interface AnimalProps {
  // Before
  animal: Animal;
  // After
  animal: NonNullable<Animal>;
}

It's not generated by the If component's condition, but since you only use the child component inside that condition, it makes some sense to design the child component's props as none nullable,under the condition that

type Animal do contain undefined.

Short answer?

You can't.

Since you've defined animal as Animal | undefined, the only way to remove undefined is to either create a guard or recast animal as something else. You've hidden the type guard in your condition property, and TypeScript has no way of knowing what is happening there, so it cannot know you are choosing between Animal and undefined. You'll need to cast it or use !.

Consider, though: this may feel cleaner, but it creates a piece of code that needs to be understood and maintained, perhaps by someone else further down the line. In essence you're creating a new language, made of React components, that someone will need to learn in addition to TypeScript.

An alternative method to conditionally output JSX is to define variables in render that contain your conditional content, such as

render() {
  const conditionalComponent = condition ? <Component/> : null;

  return (
    <Zoo>
      { conditionalComponent }
    </Zoo>
  );
}

This is a standard approach other developers will instantly recognize and not have to look up.

By using a render callback, I'm able to type that the return parameter isn't nullable.

I'm not able to modify your original If component as I don't know what your condition asserts and also what variable it asserted i.e. condition={animal !== undefined || true}

So unfortunately, I made a new component IsDefined to handle this case:

interface IsDefinedProps<T> {
  check: T;
  children: (defined: NonNullable<T>) => JSX.Element;
}

function nonNullable<T>(value: T): value is NonNullable<T> {
  return value !== undefined || value !== null;
}

function IsDefined({ children, check }: IsDefinedProps<T>) {
  return nonNullable(check) ? children(check) : null;
}

Indicating that children will be a callback, which will be passed a NonNullable of type T which is the same type as check.

With this, we'll get a render callback, which will be passed a null-checked variable.

  const aDefined: string | undefined = mapping.a;
  const bUndefined: string | undefined = mapping.b;

  return (
    <div className="App">
      <IsDefined check={aDefined}>
        {aDefined => <DoSomething message={aDefined} />} // is defined and renders
      </IsDefined>
      <IsDefined check={bUndefined}>
        {bUndefined => <DoSomething message={bUndefined} />} // is undefined and doesn't render
      </IsDefined>
    </div>
  );

I have a working example here https://codesandbox.io/s/blazing-pine-2t4sm

Related