Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Define an generic intersection type of 1...n string template types

Tags:

typescript

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

like image 408
MikingTheViking Avatar asked Jun 17 '21 19:06

MikingTheViking


People also ask

What are intersection types in TypeScript?

An intersection type combines multiple types into one. This allows you to add together existing types to get a single type that has all the features you need. For example, Person & Serializable & Loggable is a type which is all of Person and Serializable and Loggable .

What is a union in TypeScript?

In TypeScript, a union type variable is a variable which can store multiple type of values (i.e. number, string etc). A union type allows us to define a variable with multiple types. The union type variables are defined using the pipe ( '|' ) symbol between the types. The union types help in some special situations.

What is a type in TypeScript?

What is a type in TypeScript. In TypeScript, a type is a convenient way to refer to the different properties and functions that a value has. A value is anything that you can assign to a variable e.g., a number, a string, an array, an object, and a function.

Is TypeScript an operator?

An Operator is a symbol which operates on a value or data. It represents a specific action on working with data. The data on which operators operates is called operand.


1 Answers

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

like image 128
jcalz Avatar answered Dec 11 '22 20:12

jcalz