Why type is being set to unknown on optional field in particular setup with generics?

Viewed 119

I was trying to fix some types regarding DataTable on carbon-components-react, but got stuck at making one property optional. I got to the point where I created my own demo to show this problem:

import React from "react";

interface Human {
  name: string;
  age: number;
  address?: string;
}

interface ButtonProps<R extends Human> {
  name: R["name"];
  age: R["age"];
  address?: R["address"];
  onClick?: Function;
}

interface RenderProps<R extends Human> {
  human: R;
  getButtonProps(data?: ButtonProps<R>): ButtonProps<R>;
}

export interface DataTableProps<R extends Human> {
  rows: R[];
  render(props: RenderProps<R>): React.ReactNode;
}

class DataTable<R extends Human> extends React.Component<DataTableProps<R>> {
  render() {
    return this.props.rows.map((h) => {
      const buttonProps: () => Human = () => ({ ...h }); // This is only to be able to see the type of buttonProps in IDE

      return this.props.render({ human: h, getButtonProps: buttonProps });
    });
  }
}

interface TableButtonProps {
  name: string;
  address?: string;
}

const TableButton = (props: TableButtonProps) => (
  <button>Button for {props.name}</button>
);

export const Demo = () => {
  // This works
  const man1: Human = {
    name: "Anna",
    age: 30,
  };

  // This seems to be ok since "address" is optional
  // but this isn't working!
  const man2 = {
    name: "Tom",
    age: 32,
  };

  // This works
  const man3 = {
    name: "John",
    age: 40,
    address: "404 Street Unknown",
  };

  return (
    <DataTable
      rows={[man2]}
      render={({ getButtonProps }) => {
        return <TableButton {...getButtonProps()} />;
      }}
    ></DataTable>
  );
};

The error I am getting at TableButton:

Type '{ name: string; age: number; address?: unknown; onClick?: Function | undefined; }' is not assignable to type 'TableButtonProps'.
  Types of property 'address' are incompatible.
    Type 'unknown' is not assignable to type 'string | undefined'.
      Type 'unknown' is not assignable to type 'string'.  TS2322

The address property has unknown type for some reason. When I use syntax like man1 or man3 everything is working, you can replace it at rows=. Why is man2 not working even though the type is still set to extend Human? Is TypeScript getting lost?

You can test this by initializing project using npx create-react-app mydemo --template=typescript

// Edit:

interface ButtonProps<R extends Human> {
  name: Human["name"];
  age: Human["age"];
  address?: Human["address"];
  onClick?: Function;
}

Also seems to be fixing this issue and "protect" address type.

1 Answers

You probably have noticed that the problem is in address property.

For some reason it is infered as unknown.

Usually, you are getting unknown because TS is unable to infer generic parameter.

See example:

const foo = <T,>(arg: T): T => arg

// const foo: <unknown>(arg: unknown) => unknown
const result = foo() // unknown

Consider this example:

type GetAddress<T extends Human> = T extends Human ? T['address'] : never

type Result = GetAddress<{ name: 'John', age: 42 }> // unknown

Technically, { name: 'John', age: 42 } is assignable to Human because address is optional. From the other hand, T['address'] can't return undefined because it is not javascript - it is (Sparta) typesystem of typescript. Hence, if TS is unable to get/infer address property, the most safest option would be return an unknown;

Let's go back to your example.

For the sake of readability I have replaced built in react component with custom Component class:

import React from "react";

interface Human {
    name: string;
    age: number;
    address?: string;
}

interface ButtonProps<R extends Human> {
    name: R["name"];
    age: R["age"];
    address?: R["address"];
    onClick?: Function;
}

interface RenderProps<R extends Human> {
    human: R;
    getButtonProps(data?: ButtonProps<R>): ButtonProps<R>;
}


export interface DataTableProps<R extends Human, Address extends Human['address']> {
    rows: R[];
    render: (props: RenderProps<R>) => React.ReactNode;
}

class Component<Props>{
    constructor(public props: Props) {
        this.props = props
    }
}

class Foo<T extends Human> extends Component<DataTableProps<T, T['address']>>{
    render = () => {
        return this.props.rows.map(
            (row) =>
                this
                    .props
                    .render(
                        { human: row, getButtonProps: () => ({ ...row }) }
                    )
        );
    }
}

const human = { age: 42, name: 'John Doe' }

const jsx = new Foo({
    rows: [human],
    render: ({ getButtonProps }) => {
        const x = getButtonProps().address
        return <TableButton {...getButtonProps()} />;
    }
})

Please take a loot at this line Component<DataTableProps<T, T['address']>>

I have purposely added T['address'] to show you how TS will infer it. enter image description here

As you see - it is unknown. So, we ended up with object {age: number, name: string, address: unknown} which is not assignable to Human because of address.

You may ask:

Wait, but why it works with explicit type?

const human: Human = { age: 42, name: 'John Doe' }

This is because without explicit type, R is infered as {age: number, name: string} whereas with explicit Human type it is infered as {age:number, name: string, address?:string, onClick?:Function}.

Hence, if you want to fix your error, you just need to provide explicit generic parameter for DataTable class.

See example:

    <DataTable<Human> // <---------------- fix is here
      rows={[man2]}
      render={({ getButtonProps }) => {
        return <TableButton {...getButtonProps()} />;
      }}
    ></DataTable>

Btw, very interesting question.

UPDATE You can also use this helper:

/**
 * Replace all unknown props with appropriate Human props
 */
type HandleProps<T extends Human> = T & Omit<Human, keyof T>

// ............

class DataTable<R extends Human> extends React.Component<DataTableProps<HandleProps<R>>> {
    render() {
        return this.props.rows.map((h) => {
            const buttonProps: () => Human = () => ({ ...h });

            return this.props.render({ human: h, getButtonProps: buttonProps });
        });
    }
}

Playground

It will replace all unknown props with appropriate Human props

Related