Skip to content

Releases: cat394/link-generator

v10

05 Apr 01:18

Choose a tag to compare

Migration Guide: v9 β†’ v10

Overview

In v10, the link generation process has been redesigned to give users full control over how paths are constructed.

Previously, parts of the generation logic were implicitly handled by internal implementations. In v10, this behavior is now fully customizable by the user.

As a result, some options from v9 have been removed. This guide explains how to migrate.


transform β†’ transforms

Background

In v9, the transform option allowed users to override the final path based on the RouteContext.

const link = link_generator(route_config, {
  transform: (ctx) => {
    if (ctx.params) {
      return "/exist-params";
    }
  },
});

However, this approach had several issues:

  • Users had to reconstruct the entire path manually
  • Only a single callback was allowed
  • As logic grew, the resulting path became difficult to predict

v10 Approach

In v10, users can directly mutate the RouteContext before the path is generated.

The generator will build the final path after all transforms are applied.

const link = link_generator(route_config, {
  transforms: [
    (ctx) => {
      if (ctx.params) {
        ctx.path = "/exist-params";
      }
    },
  ],
});

Benefits

  • No need to return a path string
  • Clear separation of concerns via multiple transforms
  • Predictable, step-by-step transformations

RouteContext Changes

To support more flexible manipulation:

  • ctx.params is now a Map
  • ctx.query is now a URLSearchParams

Example Migration

Before (v9)

const route_config = {
  user: { path: "/users/:id" }
} as const;

const link = link_generator(route_config, {
  transform: (ctx) => {
    if (ctx.id === "user") {
      const uid = ctx.params?.id;

      if (typeof uid !== "string") return;

      const upper_uid = uid.toUpperCase();
      return "/users/" + upper_uid;
    }
  }
});

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

After (v10)

const route_config = {
  user: { path: "/users/:id" }
} as const;

const link = link_generator(route_config, {
  transforms: [
    (ctx) => {
      if (ctx.id === "user") {
        const uid = ctx.params.get("id"); // Use Map API!!

        if (typeof uid !== "string") return;

        const upper_uid = uid.toUpperCase();
        ctx.params.set("id", upper_uid);  // Use Map API!!
      }
    },
  ],
});

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

Important Behavior Change

ctx.path

In v9:

  • ctx.path contained already replaced parameters

In v10:

  • ctx.path contains the raw path template

should_append_query Removal

Use format_query_fn:

const route_config = {
  products: { path: "/products?size" }
} as const;

const link = link_generator(route_config, {
  format_query_fn: () => ""
});

link("products", undefined, { size: "small" }); // output: /products

Summary

Feature v9 v10
transform return string mutate ctx
query control should_append_query format_query_fn

v9.2.0

31 Dec 11:19

Choose a tag to compare

Changes:

Remove redundant runtime error handling
The API has always been type-safe, and this error case was already very unlikely.
The explicit runtime error was originally added mainly as a small convenience for JavaScript users.
Since TypeScript users would reliably hit a type error anyway, the extra check felt unnecessary, so it has been removed to keep the implementation simpler.

v9.1.0

27 Jul 06:06

Choose a tag to compare

What's changed?

πŸš€ New Features

🧩 should_append_query Option

A new should_append_query boolean flag has been introduced to control whether encoded query parameters are automatically appended to the generated link.

const route_config = {
  products: {
    path: "/products?order"
  }
} as const satisfies RouteConfig;

const link = link_generator(route_config, { should_append_query: false });

link("products", undefined, { order: "asc" }); // => /products (no query string!)

πŸ”§ transform Option

You can now customize link generation behavior by passing a transform function to link_generator.

  • The transform function receives a RouteContext object that provides detailed metadata about the current route, including id, path, params, and query.
  • It can return a custom string path, or return undefined to fall back to the default ctx.path.
  • This makes it easy to override only specific routes while preserving default behavior for the rest.
const link = link_generator(route_config, {
  transform: (ctx) => {
    const { id, path, params, query } = ctx;
    if (id === "products" && query.order) {
      return "/custom";
    }
    // fallback to ctx.path
  },
  should_append_query: false
});

link("products", undefined, { order: "asc" }); // => /custom
link("products"); // => /products (because no order query)

πŸ“š Documentation

Added full documentation for transform and add_query under the Options section.

Examples included for conditional transforms and query parameter control.
This is especially useful in combination with transform, where you might want full control over the output path.

v9.0.0

28 Oct 03:30

Choose a tag to compare

What's Changed?

  • Removal of create_link_generator Function

    The create_link_generator function has been removed and replaced by a new function, link_generator. The link_generator function now accepts a route_config object, internally calls the flatten_route_config function, and transforms it into a Map. This approach allows for high-speed link generation through the returned link function.

    const route_config = {
      products: {
        path: "/products",
      },
    } as const satisfies RouteConfig;
    
    const link = link_generator(route_config);
    
    link("products"); // => '/products'
  • Deprecation of flatten_route_config Function

    The flatten_route_config function was previously public because it enabled easy visual representation of the flattened types while generating the link function in create_link_generator. It was also meant to save the effort of creating specific type definitions to obtain the flattened types. However, this restricted the ability to make breaking changes to the flatten_route_config function. Since the link_generator function now calls this internally in version 9, there is no longer a need to keep it public.

  • Modification of link Function API to Accept Any Number of Query Objects

    The previous link function had a limitation where multiple identical query parameters could not be generated. This has been resolved in version 9, and the link function has been modified to accept any number of query objects starting from the third argument.

    const route_config = {
      products: {
        path: "/products?color&size",
      }
    } as const satisfies RouteConfig;
    
    const link = link_generator(route_config);
    
    link('products', undefined, { color: 'red' }, { color: 'blue' }, { color: 'green', size: 'small' }); 
    // => /products?color=red&color=blue&color=green&size=small
  • Improved Code Readability

    Variable and function names used internally have been clarified to enhance code readability.

v8.0.1

16 Oct 16:31

Choose a tag to compare

Improvements

The performance of type inference for union string types has been improved.

Before the improvement

Previously, union string types were split into an array of strings using the | operator, and type inference was performed on each union element one by one, manually checking whether each element needed type conversion. This method was inefficient, as it required sequentially extracting and processing each array element.

After the improvement

In this version, the approach has been changed to split string literal types and create a union type directly, and then perform type conversion on the union type as a whole. This eliminates the need to sequentially process each array element, significantly improving the performance of type inference.

v8.0.0

15 Oct 04:35

Choose a tag to compare

Enhanced Union String in the Constraint Area

The highlight of this milestone release, version 8, is the enhanced union strings in the constraint area!

Previously, we could apply two types of constraints to parameter types:

  1. Single Type Constraint

    "/users/:id<string>" // => { id: string }
  2. Union Type Constraint with Literal Types

    "/users/:id<(a|1|true)>" // => { id: "a" | 1 | true }

However, there were some type patterns that couldn't be achieved with this approach.

For example, you couldn’t create a union of primitive types like string|number. There may also be situations where you want to handle values like "123" or "true" as strings without automatic type conversion.

Unfortunately, this was not possible in v7. If you specified <(string|number)>, it would generate a union of string literals like "string"|"number".

To address this, in v8 we introduced manual type conversion support, allowing conversions to primitive types.

This transition is intuitive, simple, and extremely easy to implement!

The key thing to remember is to add * before the elements in the union string that need to be converted!

This means that any union string without the * prefix will be treated as a union of string literals.

Prior to v7

const route_config = {
  route_1: {
    path: "/:param<(string|number)>"
  },
 route_2: {
   path: "/:param<(a|10|true)>"
 }
} as const satisfies RouteConfig;

// ...create link generator

link("route_1", { param: "number" });
// Param type is { param: "string" | "number" }

link("route_2", { param: 10 });
// Param type is { param: "a" | 10 | true }

From v8 onwards

const route_config = {
  route_1: {
    path: "/:param<(string|number)>" // No automatic type conversions
  },
  route_2: {
    path: "/:param<(*string|*number)>"
  },
  route_3: {
    path: "/:param<(abc|123|boolean)>" // No automatic type conversions
  },
  route_4: {
    path: "/:param<(abc|*123|*boolean)>"
  }
} as const satisfies RouteConfig;

// ...create link generator

link("route_1", { param: "number" });
// Param type is { param: "string" | "number" }

link("route_2", { param: 123 });
// Param type is { param: string | number }

link("route_3", { param: "boolean" });
// Param type is { param: "abc" | "123" | "boolean" }

link("route_4", { param: true });
// Param type is { param: "abc" | 123 | boolean }

The only breaking change from v7 is this! Since it only affects type inference and does not change function implementations, you can migrate with confidence.

Other Improvements

  • Resolved ambiguities in type inference.
  • Clarified internal function names and variable names.
  • Updated and revised the documentation for v8.

The memorable version 7πŸŽ‰

10 Sep 09:00

Choose a tag to compare

Summary of Updates

This update includes a significant number of changes, effectively giving the link-generator a fresh start.

For those who may not want to go through all the details, here are the key highlights:

  1. Transitioned the naming convention from camelCase to snake_case.

  2. The query property of the ExtractRouteData type has been renamed to queries, and the path property now infers more precise values.

  3. All properties in the third argument of the link function are now optional, and the use of ? to mark parameters as optional has been removed.

  4. Passing a value of 0 to a path parameter no longer omits that parameter.

Detailed Explanation

  1. Transition from camelCase to snake_case

    To improve consistency and readability, we have transitioned from camelCase to snake_case across the project.

    We have rewritten the entire project using snake_case instead of camelCase.

    The following function names have been updated:

    • flattenRouteConfig => flatten_route_config

    • createLinkGenerator => create_link_generator

  2. Improvements to ExtractRouteData

    The path property of the ExtractRouteData type has been improved, and now returns the value without the constraint area.

    const route_config = {
      route1: {
        path: "/:param<string>?key"
      }
    } as const;
    
    // Before
    const flat_route_config = flattenRouteConfig(route_config);
    type Route1Path = ExtractRouteData<typeof flat_route_config>["route1"];
    // => /:param<string>
    
    // After Version 7
    const flat_route_config = flatten_route_config(route_config);
    type RoutePath = ExtractRouteData<typeof flat_route_config>["route1"];
    // => /:param
  3. Removed the syntax for making query parameters optional using ?

    Since all query parameters are assumed to be optional, we have adjusted the types to reflect this.

    As of version 7, the use of ? for optional query parameters has been removed.

    const route_config = {
      route1: {
        path: "/?key1&key2",
      },
    } as const;
    
    // Before
    const flat_route_config = flattenRouteConfig(route_config);
    type Route1Query = ExtractRouteData<typeof flat_route_config>;
    // => { key1: DefaultParamValue, key2: DefaultParamValue }
    
    // After Version 7
    const flat_route_config = flatten_route_config(route_config);
    type Route1Query = ExtractRouteData<typeof flat_route_config>;
    // => Partial<{ key1: DefaultParamValue, key2: DefaultParamValue }>

    This change makes all query parameters optional when generating links with the link function.

    // Before
    link("route1", undefined, {}); // Type error! The query object must have key1 and key2 properties.
    
    // If you wanted all properties to be optional you had to add a "?" as a suffix to all query names.
    const optional_route_config = {
      route1: {
        path: "/?key1?&key2?",
      },
    } as const;
    link("route1", undefined, {});
    
    // After Version 7
    // This uses the link function generated from the route_config mentioned above.
    link("route1", undefined, {}); // key1 and key2 are optional.
  4. Bug Fixes Related to Path Parameters

    Previously, passing a value of 0 to a path parameter would result in that parameter being omitted.

    In version 7, this has been fixed, and the correct path is now generated.

    const route_config = {
      route1: {
        path: "/:param"
      }
    } as const;
    
    // Before
    link("route1", { param: 0 }); // => "/"
    
    // After Version 7
    link("route1", { param: 0 }); // => "/0"

Internal Changes

  • Documentation Update

    In line with the changes above, we have rewritten all instances of camelCase in the documentation to snake_case.

    Additionally, we have removed sections of the README that referred to the now-deprecated syntax for optional query parameters.

  • Test Updates

    Previously, all tests were written in a single file. Recognizing that this was not scalable for testing multiple cases, we have split the tests into the following files:

    • path_abusolute.test.ts

    • path_constraint.test.ts

    • path_static.test.ts

    • path_with_params_and_query.test.ts

    • path_with_params.test.ts

    • path_with_queries.test.ts

    This improves scalability by allowing individual test cases to be managed separately.

  • File Name Updates

    To make file names more intuitive, we have aligned them with their respective function names.

    • generator.ts => create_link_generator.ts

    • flatConfig.ts => flatten_route_config.ts

  • Simplified Logic for Replacing Path Parameter Values

    Previously, when replacing path parameters like /:param in a route such as /route/:param with actual values, the /:param part was captured and replaced as /value. However, the initial / was unnecessary to match, so we have changed the logic to exclude the initial / using a positive lookahead. This is necessary to distinguish between port numbers and path parameters.

  • Other Changes

    • We have unified variable and type names, such as replacing instances of searchParams with query.

    • Type names starting with Exclude have been changed to start with Remove.

v6.0.0

27 Aug 01:15

Choose a tag to compare

What's changed?

  • Renamed the search property in ExtractRouteData to query.
  • Updated the path property in the ExtractRouteData type to remove query string from its value.

Example

  • Version 5

    const routeConfig = {
        products: {
            path: "/products?size"
        }
    } as const satisfies RouteConfig;
    
    type FlatResult = FlatRoutes<typeof routeConfig>;
    
    type RouteData = ExtractRouteData<FlatResult>;
    
    type ProductsRoute = RouteData["products"];
    /**
     * 
     * {
     *  path: "/products?size",
     *  params: never,
     *  search: { size: DefaultParamValue }
     * }
     * 
     */
  • Version 6

    const routeConfig = {
        products: {
            path: "/products?size"
        }
    } as const satisfies RouteConfig;
    
    type FlatResult = FlatRoutes<typeof routeConfig>;
    
    type RouteData = ExtractRouteData<FlatResult>;
    
    type ProductsRoute = RouteData["products"];
    /**
     * 
     * {
     *  path: "/products",  // NEW! Exclude query parts
     *  params: never,
     *  query: { size: DefaultParamValue } // NEW! search property -> query property
     * }
     * 
     */

v5.0.0

06 Aug 07:03

Choose a tag to compare

Major Changes:

The most significant change in this version is that path parameters can no longer be optional. This update enforces stricter type checks for parameters and allows for intuitive and type-safe query parameter definitions.

Previous Issues:

We had two main issues with the previous version of this package:

  1. Ensuring Required Path Parameters Are Set

    Consider the following code example:

    const routeConfig = {
        userPosts: {
            path: '/users/:userid/posts/:postid',
        }
    } as const satisfies RouteConfig;
    
    // ...create link function
    
    type UserPostsRouteData = ExtractRouteData<typeof flatRouteConfig>['userPosts'];
    /**
     * UserPostsRouteData = {
     *  path: 'userPosts' // Incorrect type inference has also been fixed.
     *  params: Record<'userid', DefaultParamValue> | Record<'postid', DefaultParamValue>
     *  search: never;
     * }
     */
    
    const userPostsLink = link('userPosts', { userid: '123' }); // No type errors occurred even if not all required parameters were set when generating a path.
    // => '/users/123/posts'

    In situations with multiple path parameters, the type of params was a union type of the parameters, which did not trigger type errors if not all required parameters were set. This was not the desired behavior.

    In version 5, this has been improved by making params and search parameters an intersection type of each path parameter. Now, a type error occurs if all required path parameters are not set.

    // Version 5:
    
    type UserPostsRouteData = ExtractRouteData<typeof flatRouteConfig>['userPosts'];
    /**
     * UserPostsRouteData = {
     *  path: 'userPosts' // Incorrect type inference has been fixed.
     *  params: Record<'userid', DefaultParamValue> & Record<'postid', DefaultParamValue>
     *  search: never;
     * }
     */
    const userPostsLink = link('userPosts', { userid: '123' }); // Type error, postid parameter must be set.
    
    const userPostsLink = link('userPosts', { userid: '123', postid: 1 }); // Type-safe 😊
  2. Query Parameters

    Previously, it was possible to make path parameters optional, but this led to ambiguity. Consider the following route configuration:

    const routeConfig = {
        users: {
            path: '/users/:userid?'
        }
    } as const satisfies RouteConfig;
    
    // ...create link function
    
    const usersLink = link('users');
    const userLink = link('users', { userid: '123' }); 

    This can be confusing because a single route ID generates different paths. Instead, it should be defined like this:

    const routeConfig = {
        users: {
            path: '/user',
            children: {
                user: {
                    path: '/:userid'
                }
            }
        }
    } as const satisfies RouteConfig;
    
    // ...create link function
    
    const usersLink = link('users');
    const userLink = link('users/user', { userid: '123' }); 

    While this structure might seem nested and less elegant, it enforces the rule that each route ID generates a single path. This ensures type safety for path parameters and flexibility for any extensions beyond /user.

    Consequently, optional path parameters have been deprecated. Moreover, this change eliminates the need to prefix query parameters with /, making route definitions more intuitive without requiring specific rules.

    Now, if a property is not marked as optional, all its values must be set, otherwise, a type error will occur.

    const routeConfig = {
        categories: {
            path: '/categories?size&color'
        }
    } as const satisfies RouteConfig;
    
    // ...create link function
    
    const categoriesLink = link('categories', undefined, { size: 'small' }); // Type error, please set the color parameter.
    
    const categoriesLink = link('categories', undefined, { size: 'small', color: 'red' }); // Type-safe 😊

    If you want a parameter to be optional, just add a ? after the parameter, as in previous versions.

    const routeConfig = {
        categories: {
            path: '/categories?size&color?'
        }
    } as const satisfies RouteConfig;
    
    // ...create link function
    
    const categoriesLink = link('categories', undefined, { size: 'small' }); // Type-safe 😊

Modification Details:

Previously, the path property of the ExtractRouteData type inferred the route ID. This has been corrected to reflect the actual path value of the route.

const routeConfig = {
    categories: {
        path: '/categories?size&color'
    }
} as const satisfies RouteConfig;

const flatConfig = flattenRouteConfig(routeConfig);

type CategoriesRouteData = ExtractRouteData<typeof flatConfig>['categories'];
/**
 *  CategoriesRouteData = {
 *  path: 'categories', // The path property should be the actual path value of that route!
 *  params: never;
 *  search: Record<'size', DefaultParamValue> & Record<'color', DefaultParamValue>
 * }
 */

// Version 5:
type CategoriesRouteData = ExtractRouteData<typeof flatConfig>['categories'];
/**
 *  CategoriesRouteData = {
 *  path: '/categories?size&color', // Correct!
 *  params: never;
 *  search: Record<'size', DefaultParamValue> & Record<'color', DefaultParamValue>
 * }
 */

Other Breaking Changes:

The ParamValue type is no longer exported. Instead, path parameters are typed using DefaultParamValue, and search parameters use Partial<DefaultParamValue>.

v4.0.1

27 Jul 00:24

Choose a tag to compare

Improve Type performace

We have revised the method for extracting search parameters from paths.

Let's take a simple example to illustrate this. Suppose we have the following path string literal type:

type Path = '/products/?size&color';

Previously, we handled search parameters in the same way as path parameters. First, we split the path by /:

type Separated = ['', 'products', '?size&color'];

Then, we extracted the segment starting with ? as the search parameter:

type SearchParamField = 'size&color';

However, this approach was inefficient. Unlike path parameters, search parameters always appear at the end of the path. Therefore, we changed our approach to split the path string into the portion starting with /? and the rest.

type FindSearchParamField<T> = T extends `${infer Head}/?${SearchParamField}` ? SearchParamField : T;

type SearchParamField = FindSearchParam<Path>;
// ^
// 'size&color'