Skip to content

cat394/link-generator

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Link Generator

A purple-haired, pink-eyed character named Kokomi says, 'I wish broken links would just disappear from this world!'

Creates links type-safely.

This is distributed through a package registry called JSR.

Installation

NPM:

npx jsr add @kokomi/link-generator

PNPM:

pnpm dlx jsr add @kokomi/link-generator

Deno:

deno add @kokomi/link-generator

Yarn:

yarn dlx jsr add @kokomi/link-generator

Bun:

bunx jsr add @kokomi/link-generator

Usage

  1. Define a route configuration object:

    import { link_generator, type RouteConfig } from "@kokomi/link-generator";
    
    const route_config = {
      home: {
        path: "/",
      },
      users: {
        path: "/users",
        children: {
          user: {
            path: "/:id",
          },
        },
      },
    } as const satisfies RouteConfig;
  2. Create a generator:

    const link = link_generator(route_config);
  3. Generate links:

    link("home"); // => '/'
    link("users"); // => '/users'
    link("users/user", { id: "alice" }); // => '/users/alice'

Query

The link function accepts a list of objects as query parameter generators starting from the second argument onwards. In other words, the types of the arguments from the second position onward in the link function are any number of Partial<Query> types.

Moreover, if the object for setting query parameters contains an empty string or an undefined value, the link function will not generate that query.

  1. Create a generator:

    const route_config = {
      products: {
        path: "/products?color",
      },
    } as const satisfies RouteConfig;
    
    const link = link_generator(route_config);
  2. Generate links:

    link("products", undefined, { color: "red" }, { color: "blue" }); // => '/products?color=red&color=blue'
    
    link("products", undefined, { color: "" }, { color: undefined }); // => /products

Constraint Area

The type of values for path and query parameters is string|number|boolean by default. While this is sufficient in most cases, this type can be made more strict by defining a constraint area. This is a special string that can be included in the path, like <Constraint>. Conditions can be defined within open (<) and close (>) mountain brackets. In this field, the following three type constraints can be placed on path and query parameters:

  • String Type

    You can narrow down the id to a string type by defining a condition field with a parameter name followed by the string string, as in /:id<string>.

  • Number Type

    You can narrow down the id to a number type by defining a condition field with a parameter name followed by the string number, as in /:id<number>.

  • Boolean Type

    You can narrow down the id to a boolean type by defining a condition field with a parameter name followed by the string boolean, as in /:id<boolean>.

  • Union Type

    If you want to be strict and require that params and query only accept certain values other than string, number, and boolean, use the <(Type1|Type2)> syntax.

    The type of each segment of a union type defaults to its string literal type, but you can manually cast strings that can be converted to a number, such as 1, or to boolean, such as true or false, or strings that represent types such as string, number, or boolean. To do this, simply prepend * to the string you want to cast, for example *123, *true, or *string.

  1. Create a generator:

    const route_config = {
      user: {
        path: "/users/:id<string>",
      },
      post: {
        path: "/post/:id<number>",
      },
      category: {
        path: "/categories/:id<(a|*10|*false)>",
      },
      news: {
        path: "/news?archived<boolean>",
      },
      image: {
        path: "/image?width<(auto|*number)>",
      },
    } as const satisfies RouteConfig;
    
    const link = link_generator(route_config);
  2. Generate links:

    link("user", { id: "alice" });
    // Param type: { id: string }
    
    link("post", { id: 1 });
    // Param type: { id: number }
    
    link("category", { id: "a" });
    // Param type: { id: 'a' | 10 | false }
    
    link("news", undefined, { archived: true });
    // Query type: { archived: boolean }
    
    link("image", undefined, { width: "auto" });
    // Query type: { width: 'auto' | number }

URL

If the link you want to generate contains a protocol, special type inference needs to be done, so write the protocol and domain like this, with the protocol ending in :// and no / before the domain:

const route_config = {
  external: {
    path: "https://",
    children: {
      youtube: {
        path: "youtube.com",
        children: {
          video: {
            path: "/watch?v",
          },
        },
      },
    },
  },
} as const satisfies RouteConfig;

const link = link_generator(route_config);

link("external/youtube/video", undefined, { v: "123" });
// => 'https://youtube.com/watch?v=123'

Route Type

The inferred type for each route can be obtained using the ExtractRouteData type.

const route_config = {
  user: {
    path: "/users/:id",
  },
  news: {
    path: "/news?archived<boolean>",
  },
} as const satisfies RouteConfig;

type RouteData = ExtractRouteData<FlatRoutes<typeof route_config>>;
// {
//     user: {
//         path: "/users/:id";
//         params: Record<"id", DefaultParamValue>;
//         query: never;
//     };
//     news: {
//         path: "/news";
//         params: never;
//         query: Record<"archived", boolean>;
//     };
// }

Concept

Links are fragile, so calling them by unique route ids is essential instead of hard-coding them. To ensure the uniqueness of route ids while creating them efficiently, we use TypeScript's type checking with objects. Defining properties with the same name at the same level of an object will cause a type error, ensuring no overlapping route ids. Additionally, child route ids can be made unique by prefixing them with the parent route id.

const obj = {
  route1: {},

  // Type error! An object literal cannot have multiple properties with the same name.
  route1: {},
};

By leveraging this, we can be confident that route ids do not overlap within the same level of the object. Moreover, the uniqueness of child route ids can be achieved by prefixing them with the parent route id. If the parent route id is unique, the child route ids will also be unique by necessity.

const obj = {
  parent1: {
    children: {
      child1: {},
    },
  },
  parent2: {
    children: {
      child1: {},
    },
  },
};

The generated route ids would be:

  • parent1
  • parent1/child1
  • parent2
  • parent2/child1

This approach allows flexible creation of route ids while maintaining a broad namespace.

Acknowledgements

This project was inspired by nanostores/router.

License

MIT