TypeScript Quirks: Conditionally required stuff

Say you have a component that should only render some content, provided as a prop, if another prop is true.

interface IProps {
  renderContent?: boolean;
  content?: string;
}

const Component = ({renderContent, content}: IProps) => (
  <div>
    {renderContent && content}
  </div>
);

In this case we could tell the component to render its content even if we’ve given it no content, so the following would be fine, but render nothing.

<Component renderContent />

What we can do, though, is define a couple of interfaces to tell it that content is required, when renderContent is truthy.

interface IDontRenderContentProps {
  renderContent?: false; // false or not required
  content?: void; // nothing
}

interface IRenderContentProps {
  renderContent: true;
  content: string; // Required when renderContent is true
}

We can then set these as the interface, like so.

const Component = ({renderContent, content}: IDontRenderContentProps | IRenderContentProps) => (
  <div>
    {renderContent && content}
  </div>
);

Now we can only call it with both props, or neither.

<Component /> // Happy
<Component renderContent content="content" /> // Happy

<Component renderContent /> // Not happy
<Component content="content" /> // Not happy

This could be more readable by creating a type that combines the interfaces.

type IProps = IDontRenderContentProps | IRenderContentProps;

const Component = ({renderContent, content}: IProps) => (
  <div>
    {renderContent && content}
  </div>
);

But what if we want to include some other properties that don’t rely on eachother? Well, how about another interface?

interface IRegularProps {
  foo: string;
}

type IProps = IRegularProps & (IDontRenderContentProps | IRenderContentProps);

Now foo is always required, renderContent is optional, and content is required if renderContent is true.

Something very similar can also be done with functions, classes, and methods, using overloads.

Imagine that we have an object that only ever has string keys and string values. If we define a getter function like so:

function get (obj: {[index: string]: string}, key: string, defaultValue?: string) {
  const value = obj[key];
  return typeof value === 'undefined' ? defaultValue : value;
}

The return type of this function will be inferred as string | undefined, even though if we are passing a default value we should be guaranteed to get a string value.

const thing = get({foo: 'bar'}, 'foo'); // string | undefined

Using the trick that we just learnt above will not solve the problem here because we’d just be explicitly setting the return type to string | undefined. This is where overloads come in to play.

First of all, let’s abstract that object type, as we’re going to reuse it.

interface IStringObj {
  [index: string]: string;
}

Now we can use this to define our overloads in the following way:

function get (obj: IStringObj, key: string): string | undefined;
function get (obj: IStringObj, key: string, defaultValue: string): string;
function get (obj: IStringObj, key: string, defaultValue?: string) {
  const value = obj[key];
  return typeof value === 'undefined' ? defaultValue : value;
}

Here we’ve defined a function, that can take either obj and key, and return string | undefined, or take obj, key and defaultValue and return a guaranteed string.

Now when we call our function we get the types we’d expect.

const thing = get({foo: 'bar'}, 'foo'); // string | undefined
const thing = get({foo: 'bar'}, 'foo', 'baz'); // string

You can also define overloads as interfaces, and use them for classes and class methods. You should go do some reading on them because they are awesome!

Here’s a final example of how to use overloads to say that one parameter is required if another is present.

function maybeLogSomething (): void;
function maybeLogSomething (really: true, text: string): void;
function maybeLogSomething (really?: boolean, text?: string) {
  console.log(text);
}

maybeLogSomething(); // Happy
maybeLogSomething(true, 'foo'); // Happy

maybeLogSomething(false, 'bar'); // Not happy, first value must be true if present
maybeLogSomething(true); // Not happy, requires either 0 or 2 arguments