arslancharyev31
arslancharyev31

Reputation: 1981

Variable number of generic arguments for React component in Typescript

In ReactJS with TypeScript (both latest stable at the time of writing of this post) I can define a component that conditionally renders its children if and only if a given property is truthy. One possible implementation could be:

// I will be using this type helper in subsequent examples
export type Maybe<T> = T | undefined | null

export function Only<T>(props: {
    when: Maybe<T>
    children: ReactNode
}) {
    const {when, children} = props
    return <>{when ? children : null}</>
}

It can then be used in the following manner:

const MyComponent = (props: { value: number }) => (
    <div>Value: {props.value}</div>
);

export function MyPage(){
    const [value, setValue] = useState<number>()
    // value: number | undefined

    setTimeout(()=>{
        // Simulate a request
        setValue(42)
    }, 1000)

    return (
        <div>
            <Only when={value}>
                <MyComponent value={value!}/>
            </Only>
        </div>
    )
}

It behaves as desired, but the issue is that I have to use the non-null assertion operator ! in order to pass the value to MyComponent. Fortunately, I am able to solve it by passing the narrowed type to children:

export function Only<T>(props: {
    when: Maybe<T>
    children: ReactNode | ((when: T) => ReactNode)
}) {
    const {when, children} = props;
    return when ?
        <>{children instanceof Function ? children(when) : children}</>
        : null
}

I would then use it in the following manner:

<Only when={value}>{value => // Overshadowing with narrowed type
    <MyComponent value={value}/>
}</Only>

Once again, this works as expected, albeit introducing a little bit of verbosity. But what if I need to conditionally render a component based on "truthiness" of more than one property and pass them down to children with narrowed types? One obvious solution is to nest the aforementioned Only components:

const MyComponent = (props: { value1: number, value2: string }) => (
    <>
        <div>Value1: {props.value1}</div>
        <div>Value2: {props.value2}</div>
    </>
);

export function MyPage() {
    const [value1, setValue1] = useState<number>()
    const [value2, setValue2] = useState<string>()

    setTimeout(() => {
        // Simulate a request
        setValue1(42)
        setValue2('foo')
    }, 1000)

    return (
        <div>
            <Only when={value1}>{value1 =>
                <Only when={value2}>{value2 =>
                    <MyComponent value1={value1} value2={value2}/>
                }</Only>
            }</Only>
        </div>
    )
}

Even though it will work with any number of properties, it is easy to see how this approach is not scalable given the sheer amount of boilerplate code we would need to write in order to pass several properties this way. Ideally, we would pass those properties in an array, whose members will be narrowed down in a similar manner. For example, a special case for 2 properties would look like this:

export function Only2<T, S>(props: {
    when: [Maybe<T>, Maybe<S>]
    children: ReactNode | ((when: [T, S]) => ReactNode)
}) {
    const {when, children} = props
    return when[0] && when[1] ?
        <>{children instanceof Function ? children([when[0], when[1]]) : children}</>
        : null
}

It would then be used in the following manner:

<Only2 when={[value1, value2]}>{([value1, value2]) =>
    <MyComponent value1={value1} value2={value2}/>
}</Only2>

Unfortunately, I am unable to come up with a solution for a general case, where we would be able to pass and narrow down types of any number of values in a convenient manner as shown in the snippet above. Hence my question is twofold: Is it even possible to achieve the result that I am looking for in TypeScript? And if not, are there any alternative strategies for achieving a generic component that could be used in a convenient manner as was shown in the snippets that used Only component?

Upvotes: 2

Views: 924

Answers (3)

You can use tuples here.

// Helper type to remove nulls from array
type RemoveNulls<T> = {
    [K in keyof T]: NonNullable<T[K]>
}

// simplified example of a function
// its generic parameter is a tuple
function nonNullableArray<T extends readonly any[]>(items: T, fn: (items: RemoveNulls<T>) => void) {
    if (items.some(item => item === null)) {
        return;
    }

    // Unfortunately, we have to cast type here.
    // I can't think of any other way to narrow type of array by checking it's items
    fn(items as RemoveNulls<T>);
}

declare const a: string | null;
declare const b: number | null;

// `as const` is an important part. It helps typescript to narrow down type
// from array to tuple
// (string | number | null)[] vs [string | null, number | null]
const v = nonNullableArray([a, b] as const, ([a, b]) => {
    console.log(a + b)
})

Upvotes: 3

arslancharyev31
arslancharyev31

Reputation: 1981

Thanks to @edemaine for his brilliant answer. In this answer I merely adapt his solution to the examples I provided in my question, just for the sake of competition, in case someone might find it useful:

import React, {ReactNode, useState} from "react"

type OrNull<T> = {
    [P in keyof T]: T[P] | undefined | null
}

export function Only<T>(props: {
    when: OrNull<T> | boolean // Allows usage of boolean literals
    children: ReactNode | ((when: T) => ReactNode)
}) {
    const {when, children} = props
    if (typeof when === 'boolean') {
        if (!when)
            return null
    } else {
        for (const key in when) {
            if (!when[key])
                return null
        }
    }

    return <>{children instanceof Function ? children(when as T) : children}</>
}

const MyComponent1 = (props: { value1: number }) => (
    <>
        <div>Value1: {props.value1}</div>
    </>
)

const MyComponent23 = (props: { value2: string, value3: boolean }) => (
    <>
        <div>Value2: {props.value2}</div>
        <div>Value3: {Boolean(props.value3).toString()}</div>
    </>
)


export function MyPage() {
    const [value1, setValue1] = useState<number>()
    const [value2, setValue2] = useState<string>()
    const [value3, setValue3] = useState<boolean>()

    setTimeout(() => {
        // Simulate a request
        setValue1(42)
        setValue2('foo')
        setValue3(true)
    }, 1000)

    return (
        <div>
            <Only when={{value1, value2, value3}}>{({value1, value2, value3}) =>
                <>
                    <MyComponent1 value1={value1}/>
                    <MyComponent23 value2={value2} value3={value3}/>
                </>
            }</Only>
        </div>
    )
}

Upvotes: 0

edemaine
edemaine

Reputation: 3120

Here's an approach that uses object types, and illustrates use with React components. I assumed that you have an object type without null/undefined allowed, and added that as an option.

import React, {ReactNode} from 'react';

type OrNull<T> = {
  [P in keyof T]: T[P] | undefined | null;
};

function Only<T>(props: {
    when: OrNull<T>,
    children: ReactNode | ((when: T) => ReactNode)
}) {
    const {when, children} = props;
    for (const key in when) {
        if (when[key] == null) return null;
    }
    return <>
        {children instanceof Function ? children(when as T) : children}
    </>;
}

type FooBar = {
    foo: string,
    bar: boolean,
}

const child = ({foo, bar}: FooBar) =>
    <span>{foo} is {bar}</span>;

<>
    <Only<FooBar> when={{foo: 'hi', bar: true}}>
        {child}
    </Only>

    <Only<FooBar> when={{foo: 'hi', bar: undefined}}>
        {child}
    </Only>
</>

Playground link

Upvotes: 1

Related Questions