Search code examples
javascriptreactjstypescripttypescript-typings

Different props based on a "type" prop, extending types according to a value from interface


I'm attempting to extend a type according to a value from the main interface, if the type == multiline it will have a inferface, if it is type == icon it will have another type.

import React, { memo, useCallback, ReactNode } from 'react'
import Multiline from 'src/components/Input/variaties/Multiline'
import Icon from './variaties/Icon'
import Default from './variaties/Default'
import Select from './variaties/Select'

interface InputProps {
  type?: 'multiline' | 'icon'
  className?: string
}

interface MultilineInputProps {
  // placeholder: string // Omitting in this example for simplification
  className?: string
  height?: string
}

export interface IconInputProps {
  // placeholder: string
  className?: string
  iconRight?: ReactNode
  iconLeft?: ReactNode
}

type InputComponentProps<T extends 'multiline' | 'icon'> = T extends 'multiline'
  ? MultilineInputProps
  : T extends 'icon'
    ? IconInputProps
    : never

const Input = ({
  type,
  className,
}: InputProps) => {
  const inputComponent = useCallback(() => {
    switch (type) {
      case 'select':
        return (
          <Select options={[]} placeholder={'Icon'} className={className} />
        )
      case 'icon':
        return <Icon placeholder={'Icon'} className={className} />
      case 'multiline':
        return <Multiline placeholder={'Multiline'} className={className} />
      default:
        return <Default placeholder={'Default'} className={className} />
    }
  }, [])

  return inputComponent()
}

export default memo(Input)

My tsconfig:

{
  "compilerOptions": {
    "target": "ES2017",
    "module": "ESNext",
    "lib": [
      "es6",
      "dom"
    ],
    "strict": true,
    "declaration": true,    
    "strictBindCallApply": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "strictFunctionTypes": true,
    "strictNullChecks": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "jsx": "react",
    "noImplicitThis": true,
    "allowJs": true,
    "noEmit": true,
    "baseUrl": ".",
  },
  "include": [
    "src",
  ],
  "exclude": [
    "node_modules",
    "**/*.spec.ts"
  ]
}

The issue is, this does not work: when the <Input> is used with a given type, it does not accept the corresponding props.


In response to the answer:

const Input = <T extends 'multiline' | 'icon'>({
  type,
  className,
}: InputProps & {
  type: T
} & InputComponentProps<T>) => {
  // etc.
}

enter image description here

It does not reject the incorrect prop for the type.


Solution

  • IIUC, you would like your <Input> Component to accept different props (from Multiline or Icon) based on the value of the type prop?

    In that case, you can use your InputComponentProps type by inferring the generic type parameter on the <Input> Component:

    // Add a generic type parameter on the Component
    const Input = <T extends 'multiline' | 'icon'>({
        type,
        className,
    }: InputProps & {
        type: T // Bind the generic type to a prop, so that TS can infer the actual type based on usage (could have directly modified InputProps instead)
    } & InputComponentProps<T> // Add (intersect) the extra props based on "type"
    ) => {
        return null;
    }
    
    <>
        <Input type="multiline" height="10" />
        <Input type="multiline" iconLeft="left" />
        {/*                     ~~~~~~~~ Error: Property 'iconLeft' does not exist on type 'IntrinsicAttributes & InputProps & { type: "multiline"; } & MultilineInputProps'. */}
        <Input type="icon" iconLeft="left" />
        <Input type="icon" height="10" />
        {/*                ~~~~~~ Error: Property 'height' does not exist on type 'IntrinsicAttributes & InputProps & { type: "icon"; } & IconInputProps'. */}
    </>
    

    Playground Link

    Note: beware that React.memo() loses the generic type parameter inference as is. But you can patch it, see React Generic Props gets an error whilst using function in props

    Playground Link


    You could have also simply used a discriminated union:

    const Input = (props:
      | { type: 'multiline' } & MultilineInputProps
      | { type: 'icon' } & IconInputProps
    ) {}
    

    Since there is no more generic type, React.memo does not affect it (even without above patching).

    Playground Link