How can I make a variable have the type unknown or 'Movie' assigned?

Viewed 253

I am developing a hook in which I can pass a function that makes a web request and it returns isLoading, data and error.

const [isLoading, data, error] = useApi(getMovie, idMovie, someAction);

basically I have a hook (useApi) that receives 3 parameters:

  • a function that resolves a web request
  • the parameters of this web request
  • and finally the call to a callback.

I use it like this:

const idMovie = { _id: "3" };
// callback function
const someAction = (data: unknown) => {
 // do something
 return data;
};

const [isLoading, data, error] = useApi(getMovie, idMovie, someAction);

useApi.tsx

import { AxiosPromise, AxiosResponse } from 'axios';
import { useState, useEffect } from 'react';
const useApi = (
    apiFunction: (params: unknown) => AxiosPromise,
    params = {},
    callback: (data: unknown) => void
): [boolean, unknown, null | string] => {
    const [data, setData] = useState(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<null | string>(null);

    useEffect(() => {
        apiFunction(params)
            .then(({ data }: AxiosResponse) => {
                setData(data);
                setIsLoading(false);
                if (callback) {
                    callback(data);
                }
            })
            .catch(() => {
                setError('Something went wrong');
                setIsLoading(false);
            });
    }, []); //apiFunction, params, callback]

    return [isLoading, data, error];
};

export default useApi;

getMovie corresponds to a function that solves a web request

import axios from "axios";
type getMovieParams = { _id: string };
const BASE_URL = "https://jsonplaceholder.typicode.com/todos/";
const getMovie = (params: getMovieParams): Promise<unknown> => {
  if (params) {
    const { _id } = params;
    if (_id) {
      const url = `${BASE_URL}/${_id}`;
      return axios.get(url);
    }
  }
  throw new Error("Must provide a query");
};

export default getMovie;

the code that calls this hook would look like this:

import "./styles.css";
import useApi from "./useApi";
import getMovie from "./api";
interface Movie {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

export default function App() {
  const idMovie = { _id: "3" };
  // callback function
  const someAction = (data: unknown) => {
    // do something
    return data;
  };

  const [isLoading, data, error] = useApi(getMovie, idMovie, someAction);

  const dataResponse = error ? [] : data; //type Movie
  console.log(dataResponse);
  if (isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      {error && <div>{error}</div>}
      -- Get Data Movie 1--
      <p>{dataResponse.title}</p>
    </div>
  );
}

I am getting an typing error on this line:

<p>{dataResponse.title}</p>

this is because:

const dataResponse = error ? [] : data; 

data is of type unknown, this is because it could be any type of data, but I want to specify in this case that the data type is 'Movie', the idea is to reuse this hook in another component and be able to say the type of data that will be obtained when this hook returns data.

this is my live code:

https://codesandbox.io/s/vigilant-swartz-iozn4

The reason for the creation of this question is to ask for your kind help to solve the typescript problems that are marked with a red line. (file app.tsx)

enter image description here

How can fix it? thanks

2 Answers

It is possible to use generics to capture types related to the args you're passing to a function. For instance, in the screenshot you have, TS complaints about the params, we can declare a generic to capture the params type

function useApi<Params>(
  apiFunction: (params: Params) => AxiosPromise,
  params: Params,
  callback: (data: unknown) => void
)

with this, our second hook argument will be typed as whatever type the apiFunction asks for in its arg.

we do the same to type the data we return to the callback

function useApi<Params, Return>(
  apiFunction: (params: Params) => Promise<AxiosResponse<Return>>,
  params: Params,
  callback: (data: Return) => void
)

I got the return type of the apiFunction by inspecting the types for the axios.get call

get<T = any, R = AxiosResponse<T>>(url: string, config?: AxiosRequestConfig): Promise<R>;

as you can see, in the end it returns a Promise<AxiosResponse<T>>. We intercept that T with our generic typing and name it Return

now we can use these typings in the return types and body of the hook as well

): [boolean, Return | null, null | string] {
  const [data, setData] = useState<Return | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<null | string>(null);

  useEffect(() => {
    apiFunction(params)
      .then(({ data }) => {
        setData(data);
        setIsLoading(false);
        if (callback) {
          callback(data);
        }
      })
      .catch(() => {
        setError("Something went wrong");
        setIsLoading(false);
      });
  }, []); //apiFunction, params, callback]

  return [isLoading, data, error];
}

as your Movie model is specific for the getMovie call, you can move that interface there and type the axios.get call

interface Movie {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

const getMovie = (params: getMovieParams) => {
  if (params) {
    const { _id } = params;
    if (_id) {
      const url = `${BASE_URL}/${_id}`;
      return axios.get<Movie>(url);
    }
  }
  throw new Error("Must provide a query");
};

with all these additions you'll observe some warnings on the code when you're using the hook that will force you to implement the rendering taking into account all possible values, I rewrote it like this

  ...
  const [isLoading, dataResponse, error] = useApi(
    getMovie,
    idMovie,
    someAction
  );

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (error) return <div>error</div>;

  return <div>{dataResponse?.title}</div>;
  ...

https://codesandbox.io/s/generic-hook-arg-types-s8vtt

I found that a good solution is to use generics. With this solution you should be able to provide any type parameter and as long as your API data fits those type requirements it should work.

API.tsx

import axios from "axios";

type getMovieParams = { _id: string };
const BASE_URL = "https://jsonplaceholder.typicode.com/todos/";
async function getMovie<T>(params: getMovieParams): Promise<T> {
  if (params) {
    const { _id } = params;
    if (_id) {
      const url = `${BASE_URL}/${_id}`;
      const data = await axios.get<T>(url);
      const movies = await data.data;
      console.log(movies);
      return new Promise<T>((res, rej) => {
        if (movies) {
          res(movies);
        } else {
          rej("No movies found");
        }
      });
    }
  }
  throw new Error("Must provide a query");
}

export default getMovie;

In the API the only change is the return type of the function, instead of an AxiosResponse we return a Promise of type T.

App.tsx

import "./styles.css";
import useApi from "./useApi";
import getMovie from "./api";
export interface Movie {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

export default function App() {
  const idMovie = { _id: "3" };
  // callback function
  const someAction = (data: unknown) => {
    // do something
    return data;
  };

  const [isLoading, data, error] = useApi<Movie>(getMovie, idMovie, someAction);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      {error && <div>{error}</div>}
      -- Get Data Movie 1--
      <p>{data ? data.id : ''}</p>
    </div>
  );
}

useApi.tsx

import { AxiosPromise, AxiosResponse } from "axios";
import { useState, useEffect } from "react";
function useApi<T>(
  apiFunction: (params: any) => Promise<T>,
  params = {},
  callback: (data: T) => void
): [boolean, T, null | string] {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [error, setError] = useState<null | string>(null);

  useEffect(() => {
    apiFunction(params)
      .then((res) => {
        setData(res);
        setIsLoading(false);
      })
      .catch((e) => {
        setError(e.toString());
        setIsLoading(false);
      });
  }, [apiFunction, params, callback]); //apiFunction, params, callback]

  return [isLoading, data as T, error];
}

export default useApi;

Setting useAPI to generic and it’s data to generic means we can handle use this hook to find any data that can interface with a given type T, including arrays of T.

You’ll notice the last line ‘data as T’ this will not cause an error later because you never interact with data at a potential null or undefined state, the data is null when the error exists.

codesandbox

Related