6

I'm trying to harness the power of string template literal types to add type safety to the string used to define a route.

  • For instance, a route with no parameters can be any string.
  • A route with a single parameter must include :parameterName in the path string AND include parameterName as a key/value in the params object.

I can manually set up these types and it works beautifully. But what I'd like to do is find a way to remove the need for the developer to manually chain the intersection. I'd like to handle that in my library.

type DynamicParam<S extends string> = `:${S}`
type DynamicParamRoute<T extends string> = `${string}/${DynamicParam<T>}/${string}` | `${DynamicParam<T>}/${string}` | `${string}/${DynamicParam<T>}` | `${DynamicParam<T>}`

type UserParamRoute = DynamicParamRoute<'user'>

// const bad1: UserParamRoute = 'user' // error as doesn't match ":user"
const u1: UserParamRoute = ':user'
const u2: UserParamRoute = 'prefix/:user'
const u3: UserParamRoute = ':user/suffix'
const u4: UserParamRoute = 'prefix/:user/suffix'

type TeamParamRoute = DynamicParamRoute<'team'>

const t1: TeamParamRoute = ':team'
const t2: TeamParamRoute = 'prefix/:team'
const t3: TeamParamRoute = ':team/suffix'
const t4: TeamParamRoute = 'prefix/:team/suffix'


// Combining them to get safety on a multi-param route string

type UserTeamParamRoute = UserParamRoute & TeamParamRoute // ***** I don't want my library consumer to be responsible for this. ******

// const ut1: UserTeamParamRoute = 'user/team'  // Type '"user/team"' is not assignable to type 'UserTeamParamRoute'.ts(2322)
// const ut1: UserTeamParamRoute = ':user'      // Type '":user"' is not assignable to type 'UserTeamParamRoute'.ts(2322)
// const ut1: UserTeamParamRoute = ':team'      // Type '":team"' is not assignable to type 'UserTeamParamRoute'.ts(2322)

const ut1: UserTeamParamRoute = ':user/:team'
const ut2: UserTeamParamRoute = 'prefix/:user/:team'
const ut3: UserTeamParamRoute = ':user/:team/suffix'
const ut4: UserTeamParamRoute = ':user/middle/params/:team'
const tu1: UserTeamParamRoute = ':team/:user'
const tu2: UserTeamParamRoute = 'prefix/:team/:user'
const tu3: UserTeamParamRoute = ':team/:user/suffix'
const tu4: UserTeamParamRoute = ':team/middle/params/:user'

I tried an approach using the keyof for a params object, but it creates the union of the keys. The route definition requires params object, especially if there are dynamic params. So using its keys seems almost too perfect an approach to generate the type required for the path of the route.

const params = {
  user: 'someting',
  team: 'someotherthing'
} as const

const ps = ['user', 'team'] as const

type Params = keyof typeof params


// Doesn't work, no intersection to force requiring all the keys. Just union of everything
type RouteParams = DynamicParamRoute<Params> // type RouteParams = ":user" | `${string}/:user/${string}` | `:user/${string}` | `${string}/:user` | ":team" | `${string}/:team/${string}` | `:team/${string}` | `${string}/:team`

const r1: RouteParams = ':user' // means this is valid
const p1: RouteParams = ':team' // so is this
// const rp: RouteParams = 'userteam'
// const rp0: RouteParams = ''
// const rp01: RouteParams = 'missingall'
const rp1: RouteParams = ':user/:team'
const rp2: RouteParams = 'prefix/:user/:team'
const rp3: RouteParams = ':user/:team/suffix'
const rp4: RouteParams = ':user/middle/params/:team'
const pr1: RouteParams = ':team/:user'
const pr2: RouteParams = 'prefix/:team/:user'
const pr3: RouteParams = ':team/:user/suffix'
const pr4: RouteParams = ':team/middle/params/:user'

Thanks in advance for any and all insight!

UPDATE: jcalz solution demonstrated as a GIF (I hope it isn't too compressed)

[Functioning Solution Using jcalz approach1

MikingTheViking
  • 886
  • 6
  • 11
  • "But what I'd like to do is find a way to remove the need for the developer to manually chain the intersection. I'd like to handle that in my library." what does this mean? – jered Jun 17 '21 at 21:27
  • Are you saying you don't know in advance what the URL pattern will look like? It could be `foo/bar` or `foo/:bar/baz` or any arbitrary sequence? – jered Jun 17 '21 at 21:28
  • Maybe you could provide a code example of how you envision someone ideally consuming/using your library because I don't quite follow. – jered Jun 17 '21 at 21:30
  • Hey jered, I'm writing a library for React Router which has specific string rules for how params are interpolated. Basically I wanted a type that could infer the keys from the params object and use that type to require an instance of `:${key}` for each of the keys in the path string, including some other requirements like proper separation using `/`. jcalz nailed it below! – MikingTheViking Jun 18 '21 at 13:04

1 Answers1

2

It's possible to transform unions to intersections in TypeScript by some type juggling that puts the union in question into a contravariant position. Here's how I'd re-define DynamicParamRoute<T> so that unions in T turn into intersections in the output:

type DynamicParamRoute<T extends string> = (T extends any ? (x:
    `${string}/${DynamicParam<T>}/${string}` | 
    `${DynamicParam<T>}/${string}` | 
    `${string}/${DynamicParam<T>}` | 
    `${DynamicParam<T>}`
) => void : never) extends ((x: infer I) => void) ? I : never;

I've wrapped your original definition with (T extends any ? (x: OrigDefinition<T>) => void : never) extends ((x: infer I) => void) ? I : never;. This takes unions in T and distributes them so that OrigDefinition<T> applies to each part separately, and puts them as function paramters (which are contravariant) before inferring an intersection from them. It's kind of hairy but in the end it produces the type you want:

type RouteParams = DynamicParamRoute<Params>
/* (":user" | `${string}/:user/${string}` | `:user/${string}` | `${string}/:user`) & 
(":team" | `${string}/:team/${string}` | `:team/${string}` | `${string}/:team`) */

which causes the errors you want:

const r1: RouteParams = ':user' // error!
const p1: RouteParams = ':team' // error!

I'm not sure how well intersections-of-unions of pattern template literal types scale in practice, but that's outside the scope of the question anyway.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thanks jcalz! I looked through a couple of those union to intersection stackoverflow posts but couldn't fully grok how it worked. It was mainly around using that intermediary function type that doesn't really exist that was really tripping me up. I just plugged this in and it bloody well nails it! You really are a TS Guru. – MikingTheViking Jun 18 '21 at 13:02