DEV Community

koyablue
koyablue

Posted on

Eliminate hard coded paths in front-end development!๐Ÿ‘Š๐Ÿ’ฅ

I've released an npm library based on the content of this article! Please try it out!
https://github.com/koyablue/path-kanri


I'm sure that many of you have seen something like this before.

import { useRouter } from 'next/router'

const ExampleComponent = () => {
  const router = useRouter();
  ...
  const randomFunc = () => {
    ...
   router.push(`/example/${exampleId}/${slug}`); // <-THIS!!!!!!!!!!!
  }

  return(
    <div>
      ...
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Yes, hard coded paths.
We usually avoid hard coding any value(not just paths)as much as possible. But we all make compromises. I think there are many opportunities to see something like the above.
So this time, I tried to think of a way to manage all the paths in one place and call them with functions.
Like this:

// if there is a path "/example/{exampleId}/{slug}"

// this function returns "/example/1/abcd"
getPath('example', { exampleId: 1, slug: 'abcd' })
Enter fullscreen mode Exit fullscreen mode

And also like this:

// 'https://example.com/example/1/abc/?page=1&type=fire'
// If the base url isn't registered: '/example/1/abc/?page=1&type=fire'
getFullPath('example', { exampleId: 1, slug: 'abc' }, { page: '1', type: 'fire' });
Enter fullscreen mode Exit fullscreen mode

Tech stack

  • TypeScript

Step-by-step implementation

Types

export type PathParams = { [key: string]: string | number };

// union type of object value
export type ValueOf<T> = T[keyof T];
Enter fullscreen mode Exit fullscreen mode

Functions

// types
import { ValueOf, PathParams } from '../types/index';

// constants
import { missingRequiredParametersMsg, invalidParametersMsg } from './constants';

/**
 *
 *
 * @template TPathNameAndUriMap
 * @param {TPathNameAndUriMap} pathNameAndUriMap ex) { name: '/example/{a}/{b}', ... }
 * @param {string} [baseUrl] ex) 'https://api.example.com'
 * @return {*} functions
 */
const pathManager = <TPathNameAndUriMap extends { [s: string]: unknown; }>(
  pathNameAndUriMap: TPathNameAndUriMap,
  baseUrl: string = '',
) => {
  type PathName = keyof typeof pathNameAndUriMap;
  type Uri = ValueOf<typeof pathNameAndUriMap>;

  /**
   * Returns array of parameter name
   * ex) getParamNamesFromRawUri('/example/{exampleId}/{slug}') -> ['exampleId', 'slug']
   *
   * @param {Uri} rawUri
   * @return {*}  {string[]}
   */
  const getParamNamesFromRawUri = (rawUri: Uri): string[] => {
    const rawUriStr = String(rawUri);
    const paramNamesWithBrackets = rawUriStr.match(/{.+?}/g);
    if (paramNamesWithBrackets === null) return [];

    return paramNamesWithBrackets.map((paramNameWithBrackets) => paramNameWithBrackets.replace(/{|}/g, ''));
  };
  type ParamNames = ReturnType<typeof getParamNamesFromRawUri>;

  /**
   * Validate number of parameters and parameter names
   *
   * @param {PathParams} params
   * @param {ParamNames} paramNames
   * @param {PathName} pathName
   * @param {TPathNameAndUriMap[keyof TPathNameAndUriMap]} rawUri
   */
  const validateParams = (
    params: PathParams,
    paramNames: ParamNames,
    pathName: PathName,
    rawUri: TPathNameAndUriMap[keyof TPathNameAndUriMap],
  ) => {
    const paramsKeys = Object.keys(params);

    if (paramsKeys.length !== paramNames.length) {
      throw new Error(missingRequiredParametersMsg(String(pathName), String(rawUri)));
    }

    if (!paramsKeys.every((paramKey) => paramNames.includes(paramKey))) {
      throw new Error(invalidParametersMsg(String(pathName), String(rawUri)));
    }
  };

  /**
   * Generate query params string from object
   * ex) generateQueryParamsStr({ page: '1', type: 'fire' }) => 'page=1&type=fire'
   *
   * @param {Record<string, string>} paramsObj
   * @return {*}  {string}
   */
  const generateQueryParamsStr = (paramsObj: Record<string, string>): string => (
    new URLSearchParams(paramsObj).toString()
  );

  /**
   * Returns full path
   *
   * @param {string} path
   * @return {*}  {string}
   */
  const withBaseUrl = (path: string): string => `${baseUrl}${path}`;

  /**
   * Returns a path with query parameters
   *
   * @param {string} path
   * @param {Record<string, string>} queryParams
   * @return {*}  {string}
   */
  const withQueryParams = (path: string, queryParams: Record<string, string>): string => `${path}/?${generateQueryParamsStr(queryParams)}`;

  /**
   * Get path
   *
   * getActualUri('example', { exampleId: 1, slug: 'abcd' }) -> '/example/1/abcd'
   *
   * @param {PathName} pathName
   * @param {PathParams} [params]
   * @return {*}  {string}
   */
  const getPath = (
    pathName: PathName,
    params?: PathParams,
    queryParams?: Record<string, string>,
  ): string => {
    const rawUri = pathNameAndUriMap[pathName]; // ex) '/example/{exampleId}/{slug}'

    const paramNames = getParamNamesFromRawUri(rawUri); // ex) ['exampleId', 'slug']
    const rawUriStr = String(rawUri); // This is just for type conversion

    // Return if the path doesn't contain any parameter placeholder
    if (!paramNames.length) return withBaseUrl(rawUriStr);

    // The path contains parameter placeholders but params doesn't provided as the 2nd argument
    if (!params) {
      throw new Error(missingRequiredParametersMsg(String(pathName), rawUriStr));
    }

    // Throw error if the params are invalid
    validateParams(params, paramNames, pathName, rawUri);

    // Fill the parameter placeholder with params
    // '/example/{exampleId}/{slug}' -> '/example/1/abcd'
    let pathToReturn = rawUriStr;
    paramNames.forEach((paramName) => {
      pathToReturn = pathToReturn.replace(`{${paramName}}`, String(params[paramName]));
    });

    // ex) 'https://example.com/example/1/abcd/?page=1&type=fire'
    if (queryParams) return withQueryParams(pathToReturn, queryParams);

    return pathToReturn;
  };

  const getFullPath = (
    pathName: PathName,
    params?: PathParams,
    queryParams?: Record<string, string>,
  ): string => withBaseUrl(getPath(pathName, params, queryParams));

  return { getPath, getFullPath } as const;
};

export default pathManager;
Enter fullscreen mode Exit fullscreen mode
  • constants
const nameAndUri = (pathName: string, rawUri: string) => `[NAME: ${pathName}][URI: ${rawUri}]`;

export const missingRequiredParametersMsg = (pathName: string, rawUri: string) => `Missing required parameters for ${nameAndUri(pathName, rawUri)}.`;

export const invalidParametersMsg = (pathName: string, rawUri: string) => `Given parameters are not valid for ${nameAndUri(pathName, rawUri)}.`;
Enter fullscreen mode Exit fullscreen mode

How to use it

in .env:

APP_BASE_URL=https://example.com
API_BASE_URL=https://api.example.com
Enter fullscreen mode Exit fullscreen mode

make util function as well:

export const getAppBaseUrl = (): string => process.env.APP_BASE_URL || '';

export const getApiBaseUrl = (): string => process.env.API_BASE_URL || '';
Enter fullscreen mode Exit fullscreen mode

Then register paths.

import pathManager from 'path-kanri';
import { getApiBaseUrl } from '../path/to/utils'; 

const {
  getPath: getApiRoute,
  getFullPath: getApiRouteFull,
} = pathManager({
  register: '/register',
  users: '/users',
  userProfile: '/users/{userId}',
  userPosts: '/users/{userId}/posts',
  userPost: '/users/{userId}/posts/{postId}',
}, getApiBaseUrl());

export { getApiRoute, getApiRouteFull };
Enter fullscreen mode Exit fullscreen mode
import pathManager from 'path-kanri';
import { getAppBaseUrl } from '../path/to/utils'; 

const {
  getPath: getWebRoute,
  getFullPath: getWebRouteFull,
} = pathManager({
  home: '/dashboard',
  userProfile: '/users/{userId}',
  userPosts: '/users/{userId}/posts',
  userPost: '/users/{userId}/posts/{postId}',
}, getApiBaseUrl());

export { getWebRoute, getWebRouteFull };
Enter fullscreen mode Exit fullscreen mode

Use it in redirect logics.

import { useRouter } from 'next/router';
import { getWebRoute } from '../routes/web';

const ExampleComponent = () => {
  const router = useRouter();

  const { getPath } = usePaths();

  const randomFunc = () => {
    ...
    const userId = user.id;
    const postId = post.id;
    router.push(getWebRoute('userPost', { userId, postId }));
  };

  return(
    <div>
      ...
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

in API request logics as well.

import axios, { AxiosInstance } from 'axios';
import { apiBaseUrl } from '../const/api';
import { getApiEndpoint } from '../routes/api';
import { UserRegistrationRequest } from '../../types';

/**
 * Base axios instance for API calls
 *
 * @param {string} token
 * @return {*}  {AxiosInstance}
 */
export const axiosBase = (token: string = ''): AxiosInstance => {
  const bearerConfig = token ? `Bearer ${token}` : '';

  return axios.create({
    baseURL: apiBaseUrl,
    headers: {
      accept: 'application/json',
      'Content-Type': 'application/json',
      Authorization: bearerConfig,
    },
  });
};

/**
 * User registration API call
 *
 * @param {UserRegistrationRequest} values
 * @return {*}  {Promise<void>}
 */
export const registerUser = async (
  values: UserRegistrationRequest,
): Promise<void> => {
  const res = await axiosBase().post(getApiEndpoint('register'), values);

  return res.data;
};
Enter fullscreen mode Exit fullscreen mode

Of course auto type completion is available in the editors.
Image description


That's all. I hope this article was helpful for you.

How do you usually manage URLs and paths? If you have better ways, let me know in the comments!

And if you found this article helpful, please share it on social media!


(Original article I wrote in Japanese: https://zenn.dev/koyabluetech/articles/b5c23cbd78cc64)

Top comments (0)