Identity functions
Last tended May 19, 2024.
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.defineConfig } from 'vite'
No intellisense; TypeScript doesn't know this is a vite config object.const const config1: {
base: string;
}
config1 = { base: string
base: '/' };
defineConfig's parameter is typed. Behold: valid config options!const const config2: UserConfig
config2 = 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.defineConfig({ b- base
- build
UserConfig.base?: string | undefined
Base public path when served in development or production.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: string
path: '/hello/world',
handler: (...args: any[]) => any
handler: (message: string
message: string)=> message: string
message.String.length: number
Returns the length of a String object.length
}, {
path: string
path: '/goodbye/moon',
handler: (...args: any[]) => any
handler: ()=>{}
}])
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:
- The
Route.path
values must be narrowed to string literals - The
Route.handler
values must be narrowly typed as their exact function signature - 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: string
path: string;
handler: (...args: any[]) => any
handler: (...args: any[]
args: any[])=> any
}
function function DefineRoutes<const TRoutes extends ReadonlyArray<Route>>(routes: TRoutes): TRoutes
DefineRoutes<const function (type parameter) TRoutes in DefineRoutes<const TRoutes extends ReadonlyArray<Route>>(routes: TRoutes): TRoutes
TRoutes extends interface ReadonlyArray<T>
ReadonlyArray<type Route = {
path: string;
handler: (...args: any[]) => any;
}
Route>>(routes: const TRoutes extends ReadonlyArray<Route>
routes: function (type parameter) TRoutes in DefineRoutes<const TRoutes extends ReadonlyArray<Route>>(routes: TRoutes): TRoutes
TRoutes){
return routes: const TRoutes extends ReadonlyArray<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) => number
handler: (message: string
message: string)=> message: string
message.String.length: number
Returns the length of a String object.length
}, {
path: "/goodbye/moon"
path: '/goodbye/moon',
handler: () => void
handler: ()=>{}
}])
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} pairsfunction function MakeRouter<TRoutes extends ReadonlyArray<Route>>(routes: TRoutes): { [T in TRoutes[number] as T["path"]]: T["handler"]; }
MakeRouter<function (type parameter) TRoutes in MakeRouter<TRoutes extends ReadonlyArray<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 ReadonlyArray<Route>
routes:function (type parameter) TRoutes in MakeRouter<TRoutes extends ReadonlyArray<Route>>(routes: TRoutes): { [T in TRoutes[number] as T["path"]]: T["handler"]; }
TRoutes
) {
return routes: TRoutes extends ReadonlyArray<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.map(route: Route
route=>({[route: Route
route.path: string
path]: route: Route
route.handler: (...args: any[]) => any
handler})) as
{[function (type parameter) T
T in function (type parameter) TRoutes in MakeRouter<TRoutes extends ReadonlyArray<Route>>(routes: TRoutes): { [T in TRoutes[number] as T["path"]]: T["handler"]; }
TRoutes[number] as function (type parameter) T
T['path']]: function (type parameter) T
T['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 safetyconst 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.