Identity functions

🌱May 18, 2024.
Last tended May 19, 2024.
seedling 🌱
5 minutes read ⏱

Identity functions are a tool to improve type information. They take the form of a function that provides TypeScript with extra type information and typically have a return value that is identical to argument passed to them. Typically identity functions are used to either provide a library’s users with better type information, or for a library to get better type information from a user. In both cases, the goal seems to be improving ease of use and developer experience.

Providing type hints to API consumers

Libraries with many configuration options commonly use an identity function to make it easier for users to configure them. This is typically done by exporting a function with a name like defineConfig(). It will take a parameter typed identically to the library’s configuration object. Since this tells the IDE what values exist on the object, it can display those options to the users. I much prefer this in-editor experience to tabbing back and forth between my code and the docs. Some tools I use that take this approach are:

For example, compare the intellisense help with and without Vite’s defineConfig():

import { function defineConfig(config: UserConfig): UserConfig (+3 overloads)
Type helper to make it easier to use vite.config.ts accepts a direct {@link UserConfig } object, or a function that returns it. The function receives a {@link ConfigEnv } object that exposes two properties: `command` (either `'build'` or `'serve'`), and `mode`.
defineConfig
} from 'vite'
No intellisense; TypeScript doesn't know this is a vite config object.
const const config1: {
base: string;
}
config1
= { base: stringbase: '/' };
defineConfig's parameter is typed. Behold: valid config options!
const const config2: UserConfigconfig2 = function defineConfig(config: UserConfig): UserConfig (+3 overloads)
Type helper to make it easier to use vite.config.ts accepts a direct {@link UserConfig } object, or a function that returns it. The function receives a {@link ConfigEnv } object that exposes two properties: `command` (either `'build'` or `'serve'`), and `mode`.
defineConfig
({ b
  • base
  • build
UserConfig.base?: string | undefined
Base public path when served in development or production.
@default'/'
ase
: '/' });

I hope the identity function approach rapidly gains popularity. The developer experience is excellent, and it uses simple syntax that is easy to understand. I find it much more intuitive than JSDoc type import annotations.

Getting narrower type information from API consumers

Imagine you are building a basic router. It needs to know the path and handler for each route, and it takes an array of objects with these values. Trivially, that might look something like this:

type Route = {
	path: string;
	handler: (...args: any[])=> any
}
function DefineRoutes(routes: Route[]){
	return routes;
}

This straightforward approach will produce a runtime array of values, but the type system doesn’t know the exact paths or exact parameters for each route. TypeScript only knows that paths are strings and handlers are functions. Hovering over the highlighted values below reveals that TypeScript knows only their broad types.

const const routes: Route[]routes = function DefineRoutes(routes: Route[]): Route[]DefineRoutes([{
    path: stringpath: '/hello/world', 
    handler: (...args: any[]) => anyhandler: (message: stringmessage: string)=> message: stringmessage.String.length: number
Returns the length of a String object.
length
}, { path: stringpath: '/goodbye/moon', handler: (...args: any[]) => anyhandler: ()=>{} }])

Now let’s build a version of this API where the type system has extremely narrow types for each value. That can provide many benefits that are impossible otherwise:

  • A type safe API that only allows calls to a route that match the route handler’s signature
  • When specifying a route, instead of any string being allowed it could be limited to only defined routes, which would all pop up as suggestions in the intellisense

Router will need to be a generic so the type system can infer narrower types. Specifically, it needs to narrow in three ways:

  1. The Route.path values must be narrowed to string literals
  2. The Route.handler values must be narrowly typed as their exact function signature
  3. Both 1&2 need to happen separately for each value in the array

To do this requires a const type parameter (introduced in TypeScript 5.0) and until TS 5.3 it requires using a ReadonlyArray as well. This should do it:

type type Route = {
path: string;
handler: (...args: any[]) => any;
}
Route
= {
path: stringpath: string; handler: (...args: any[]) => anyhandler: (...args: any[]args: any[])=> any } function function DefineRoutes<const TRoutes extends readonly Route[]>(routes: TRoutes): TRoutesDefineRoutes<const function (type parameter) TRoutes in DefineRoutes<const TRoutes extends readonly Route[]>(routes: TRoutes): TRoutesTRoutes extends interface ReadonlyArray<T>ReadonlyArray<type Route = {
path: string;
handler: (...args: any[]) => any;
}
Route
>
>(routes: const TRoutes extends readonly Route[]routes: function (type parameter) TRoutes in DefineRoutes<const TRoutes extends readonly Route[]>(routes: TRoutes): TRoutesTRoutes){
return routes: const TRoutes extends readonly Route[]routes; }

The const type parameter tells TypeScript to infer the narrowest type possible. It does a great job at it, too. Check them out for the same call to Router as above, but using the new typing for it:

const const routes: readonly [{
readonly path: "/hello/world";
readonly handler: (message: string) => number;
}, {
readonly path: "/goodbye/moon";
readonly handler: () => void;
}]
routes
= function DefineRoutes<readonly [{
readonly path: "/hello/world";
readonly handler: (message: string) => number;
}, {
readonly path: "/goodbye/moon";
readonly handler: () => void;
}]>(routes: readonly [{
readonly path: "/hello/world";
readonly handler: (message: string) => number;
}, {
...;
}]): readonly [...]
DefineRoutes
([{
path: "/hello/world"path: '/hello/world', handler: (message: string) => numberhandler: (message: stringmessage: string)=> message: stringmessage.String.length: number
Returns the length of a String object.
length
}, { path: "/goodbye/moon"path: '/goodbye/moon', handler: () => voidhandler: ()=>{} }])

Notice that both the path and handler values are getting typed as narrowly as possible. That makes it possible to do all sorts of cool things with them:


type type Routes = "/hello/world" | "/goodbye/moon"Routes = typeof const routes: readonly [{
readonly path: "/hello/world";
readonly handler: (message: string) => number;
}, {
readonly path: "/goodbye/moon";
readonly handler: () => void;
}]
routes
[number]['path']
Routes is a type union of the paths passed to `Router()`

If you brave slightly gnarlier TypeScript, you can get some truly incredible results:

Create a router of {path: handler} pairs
function function MakeRouter<TRoutes extends readonly Route[]>(routes: TRoutes): { [T in TRoutes[number] as T["path"]]: T["handler"]; }MakeRouter<function (type parameter) TRoutes in MakeRouter<TRoutes extends readonly Route[]>(routes: TRoutes): { [T in TRoutes[number] as T["path"]]: T["handler"]; }TRoutes extends interface ReadonlyArray<T>ReadonlyArray<type Route = {
path: string;
handler: (...args: any[]) => any;
}
Route
>>(
routes: TRoutes extends readonly Route[]routes:function (type parameter) TRoutes in MakeRouter<TRoutes extends readonly Route[]>(routes: TRoutes): { [T in TRoutes[number] as T["path"]]: T["handler"]; }TRoutes ) { return routes: TRoutes extends readonly Route[]routes.ReadonlyArray<Route>.map<{
[x: string]: (...args: any[]) => any;
}>(callbackfn: (value: Route, index: number, array: readonly Route[]) => {
[x: string]: (...args: any[]) => any;
}, thisArg?: any): {
[x: string]: (...args: any[]) => any;
}[]
Calls a defined callback function on each element of an array, and returns an array that contains the results.
@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
map
(route: Routeroute=>({[route: Routeroute.path: stringpath]: route: Routeroute.handler: (...args: any[]) => anyhandler})) as
{[function (type parameter) TT in function (type parameter) TRoutes in MakeRouter<TRoutes extends readonly Route[]>(routes: TRoutes): { [T in TRoutes[number] as T["path"]]: T["handler"]; }TRoutes[number] as function (type parameter) TT['path']]: function (type parameter) TT['handler']}; } const const router: {
"/hello/world": (message: string) => number;
"/goodbye/moon": () => void;
}
router
= function MakeRouter<readonly [{
readonly path: "/hello/world";
readonly handler: (message: string) => number;
}, {
readonly path: "/goodbye/moon";
readonly handler: () => void;
}]>(routes: readonly [{
readonly path: "/hello/world";
readonly handler: (message: string) => number;
}, {
...;
}]): {
...;
}
MakeRouter
(const routes: readonly [{
readonly path: "/hello/world";
readonly handler: (message: string) => number;
}, {
readonly path: "/goodbye/moon";
readonly handler: () => void;
}]
routes
);
and easily invoke them with proper type safety
const router: {
"/hello/world": (message: string) => number;
"/goodbye/moon": () => void;
}
router
['/
  • /goodbye/moon
  • /hello/world
hello/world']('Greetings!')

Pretty nifty, eh? It could be fun to build a router API that provides a nice client object and outputs bindings for popular backend frameworks like Express, fastify, and hono by doing type transformations on the input routes.