Typescript property does not exist on extended type

Viewed 1161

I have a react component which either takes the to (react-router-dom prop) or href prop depending on where I want to redirect the user; whether it's within the app or to an external url. Typescript complains about my type with

Property 'to' does not exist on type 'LinkType'.

Property 'href' does not exist on type 'LinkType'.

This is how I defined my type

import React from 'react';
import { Link } from 'react-router-dom';

interface LinkBase {
  label: string;
  icon?: JSX.Element;
}

interface RouterLink extends LinkBase {
  to: string;
}

interface HrefLink extends LinkBase {
  href: string;
}

type LinkType = RouterLink | HrefLink;

export function NavLink(props: LinkType) {
  const { to, label, icon, href } = props;  // TS complains about those to and href

  return(
    {to ? (
      <Link to={to}>{label}</Link>
    ) : (
      <a href={href}>{label}</a>
    )}
  );
}
2 Answers

Because LinkType is a union, HrefLink and RouterLink are both assignable to LinkType. HrefLink does not have "to" on it, and RouterLink does not have "href" on it. Therefore typescript can not guarantee that either of those properties exists on the union. You can use a type guard to do what you're trying to while also satisfying TypeScript.

import React from 'react';
import { Link } from 'react-router-dom';

interface LinkBase {
  label: string;
  icon?: JSX.Element;
}

interface RouterLink extends LinkBase {
  to: string;
}

interface HrefLink extends LinkBase {
  href: string;
}

type LinkType = RouterLink | HrefLink;

function isRouterLink(v: unknown): v is RouterLink {
  return v && Object.prototype.hasOwnProperty.call(v, 'to');
}

function isHrefLink(v: unknown): v is HrefLink {
  return v && Object.prototype.hasOwnProperty.call(v, 'href');
}

export function NavLink(props: LinkType) {
  const { label, icon } = props; 

  if (isRouterLink(props)) {
    const { to } = props; // typechecks now
    return (<> ... </>)
  }

  if (isHrefLink(props)) {
    const { href } = props;

    return (<> ... </>);
  }

  return (<> ... </>);
}

Another option would be using a "tagged" union to get around the need of a type guard function. These go by some different names depending on who you're talking to. Other names you can find these by are "discriminating union" and "disjoint union" to name a couple.

interface LinkBase {
  label: string;
  icon?: JSX.Element;
}

interface RouterLink extends LinkBase {
  type: 'router';
  to: string;
}

interface HrefLink extends LinkBase {
  type: 'href';
  href: string;
}

type LinkType = RouterLink | HrefLink;

export function NavLink(props: LinkType) {
  const { label, icon } = props; 

  if (props.type === 'router') {
    const { to } = props; // typechecks now
    return (<> ... </>);
  }

  if (props.type === 'href') {
    const { href } = props;

    return (<> ... </>);
  }

 return (<> ... </>); 
}

So then if you inspect the type of LinkType['type'] you'll see 'router' | 'href', and TypeScript can automatically narrow the type for you if you do an if statement on this type property of LinkType.

You can do it like this:

type LinkType = RouterLink & HrefLink;

That way you don't have to narrow the union with code

Edit 1.
Alex D. raised a possible side effect of this approach. Basically, you'll not be able to define a LinkType object without fulfilling all the properties (href, label, to) This can be tackled by using Partials or simply declaring href and to as optional, e.g

interface HrefLink extends LinkBase {
  href?: string;
}
Related