React/Typescript forwardRef types for an element which returns either an input or textArea

While this by no means fixes the problem with React.forwardProps, an alternative would be to work around it and instead utilize an innerRef property. Then you can enforce types on the innerRef property. Achieves the same result that you want, but with flexible typing, less overhead, and no instantiation.

Working demo:

Edit Typescript - Switch Component


components/Label/index.tsx

import * as React from "react";
import { FC, LabelProps } from "~types";

/*
  Field label for form elements

  @param {string} name - form field name
  @param {string} label - form field label 
  @returns {JSX.Element}
*/
const Label: FC<LabelProps> = ({ name, label }) => (
  <label className="label" htmlFor={name}>
    {label}&#58;
  </label>
);

export default Label;

components/Fields/index.tsx

import * as React from "react";
import Label from "../Label";
import { FC, InputProps, TextAreaProps } from "~types";

/*
  Field elements for a form that are conditionally rendered by a fieldType
  of "input" or "textarea".

  @param {Object} props - properties for an input or textarea
  @returns {JSX.Element | null} 
*/
const Field: FC<InputProps | TextAreaProps> = (props) => {
  switch (props.fieldType) {
    case "input":
      return (
        <>
          <Label name={props.name} label={props.label} />
          <input
            ref={props.innerRef}
            name={props.name}
            className={props.className}
            placeholder={props.placeholder}
            type={props.type}
            value={props.value}
            onChange={props.onChange}
          />
        </>
      );
    case "textarea":
      return (
        <>
          <Label name={props.name} label={props.label} />
          <textarea
            ref={props.innerRef}
            name={props.name}
            className={props.className}
            placeholder={props.placeholder}
            rows={props.rows}
            cols={props.cols}
            value={props.value}
            onChange={props.onChange}
          />
        </>
      );
    default:
      return null;
  }
};

export default Field;

components/Form/index.tsx

import * as React from "react";
import Field from "../Fields";
import { FormEvent, FC, EventTargetNameValue } from "~types";

const initialState = {
  email: "",
  name: "",
  background: ""
};

const Form: FC = () => {
  const [state, setState] = React.useState(initialState);
  const emailRef = React.useRef<HTMLInputElement>(null);
  const nameRef = React.useRef<HTMLInputElement>(null);
  const bgRef = React.useRef<HTMLTextAreaElement>(null);

  const handleChange = React.useCallback(
    ({ target: { name, value } }: EventTargetNameValue) => {
      setState((s) => ({ ...s, [name]: value }));
    },
    []
  );

  const handleReset = React.useCallback(() => {
    setState(initialState);
  }, []);

  const handleSubmit = React.useCallback(
    (e: FormEvent<HTMLFormElement>) => {
      e.preventDefault();

      const alertMessage = Object.values(state).some((v) => !v)
        ? "Must fill out all form fields before submitting!"
        : JSON.stringify(state, null, 4);

      alert(alertMessage);
    },
    [state]
  );

  return (
    <form className="uk-form" onSubmit={handleSubmit}>
      <Field
        innerRef={emailRef}
        label="Email"
        className="uk-input"
        fieldType="input"
        type="email"
        name="email"
        onChange={handleChange}
        placeholder="Enter email..."
        value={state.email}
      />
      <Field
        innerRef={nameRef}
        label="Name"
        className="uk-input"
        fieldType="input"
        type="text"
        name="name"
        onChange={handleChange}
        placeholder="Enter name..."
        value={state.name}
      />
      <Field
        innerRef={bgRef}
        label="Background"
        className="uk-textarea"
        fieldType="textarea"
        rows={5}
        name="background"
        onChange={handleChange}
        placeholder="Enter background..."
        value={state.background}
      />
      <button
        className="uk-button uk-button-danger"
        type="button"
        onClick={handleReset}
      >
        Reset
      </button>
      <button
        style={{ float: "right" }}
        className="uk-button uk-button-primary"
        type="submit"
      >
        Submit
      </button>
    </form>
  );
};

export default Form;

types/index.ts

import type {
  FC,
  ChangeEvent,
  RefObject as Ref,
  FormEvent,
  ReactText
} from "react";

// custom utility types that can be reused
type ClassName = { className?: string };
type InnerRef<T> = { innerRef?: Ref<T> };
type OnChange<T> = { onChange: (event: ChangeEvent<T>) => void };
type Placeholder = { placeholder?: string };
type Value<T> = { value: T };

// defines a destructured event in a callback
export type EventTargetNameValue = {
  target: {
    name: string;
    value: string;
  };
};

/*
  Utility interface that constructs typings based upon passed in arguments

  @param {HTMLElement} E - type of HTML Element that is being rendered
  @param {string} F - the fieldType to be rendered ("input" or "textarea")
  @param {string} V - the type of value the field expects to be (string, number, etc)
*/
interface FieldProps<E, F, V>
  extends LabelProps,
    ClassName,
    Placeholder,
    OnChange<E>,
    InnerRef<E>,
    Value<V> {
  fieldType: F;
}

// defines props for a "Label" component
export interface LabelProps {
  name: string;
  label: string;
}

// defines props for an "input" element by extending the FieldProps interface
export interface InputProps
  extends FieldProps<HTMLInputElement, "input", ReactText> {
  type: "text" | "number" | "email" | "phone";
}

// defines props for an "textarea" element by extending the FieldProps interface
export interface TextAreaProps
  extends FieldProps<HTMLTextAreaElement, "textarea", string> {
  cols?: number;
  rows?: number;
}

// exporting React types for reusability
export type { ChangeEvent, FC, FormEvent };

index.tsx

import * as React from "react";
import { render } from "react-dom";
import Form from "./components/Form";
import "uikit/dist/css/uikit.min.css";
import "./index.css";

render(<Form />, document.getElementById("root"));