Darryl Noakes
Darryl Noakes

Reputation: 2790

How to require the values of an object's properties to contain certain substrings?

I am experimenting with ideas for an API config system, and would like some help on typing a config object.

Note: In terms of the API, this is a resources/endpoints config, with each resource/endpoint having a specified path which must contain certain parameters. I will talk more in terms of TypeScript, to simplify terminology and avoid confusion.

The basic problem is that I have a config object, where each of the properties has a nested property that must contain certain substrings. I have this schema object, which defines what substrings the nested property must contain (the substrings specified in this object are each wrapped in curly braces to create the actual substring):

const pathsSchema = {
  foo: {
    params: ["id"], // substring would be `{id}`
  },
} as const;

(I generate a type from this object. I can't remember why I decided it needs to be an object instead of just a type. If they want to, answerers can ignore it and just use a type directly.)

The corresponding config object for this schema would be something like the following:

const config = {
  foo: {
    path: "foo?id={id}",
  },
};

With a single substring, it is simple, as I can just type it with the following:

Path<param extends string> = `${string}{${param}}${string}`

type Paths<schema = typeof pathsSchema> = {
  [name in keyof schema]: {
    path: Path<schema[name]["param"]>
  };
};

However, a similar approach with multiple substrings would require generating every possible permutation, which is obviously not a good idea.
Update: This is moot! See update at bottom of question.

So currently I have this generic type that resolves to true if a string contains the required substrings:

type CheckPath<path extends string, params extends string[]> =
  // A. If there is a first param, get it.
  params extends [infer param extends string, ...infer rest extends string[]]
    ? path extends `${string}{${param}}${string}` // B. If path contains param...
      ? CheckPath<path, rest> // B. ...check next param...
      : false // B. ...else, path is invalid.
    : true; // A. There are no more params, so path is valid.

I then have the following framework. The defineConfig helper is a pattern used to provide typing in a separate, "user"-provided config file; e.g. vite.config.ts for Vite.

const defineConfig = (config: Paths) => config;

const config = defineConfig({
  foo: {
    path: "foo?id={id}",
  },
});

Is there some way I can require that the object passed to defineConfig passes checking with CheckPath? I do not like these kind of "validation" types, but I don't know if there's another way. Update: This is moot: template literal types can be intersected! Yay TypeScript! (Thanks to Alex Wayne for pointing this out.)
So now my question boils down to: how do I go from this schema with a map of names-to-string-arrays to a map of names-to-intersections-of-template-literal-types?

Upvotes: 0

Views: 87

Answers (1)

Alex Wayne
Alex Wayne

Reputation: 187232

I've found its usually best to avoid validation types that return true in favor of a type that simply defines the data you want.


One cool thing is that you can intersect string template types, and both must apply.

type AB = `${string}a${string}` & `${string}b${string}`
const str1: AB = `__a_b__` // fine
const str2: AB = `b_a123` // fine
const str3: AB = `a123` // error, no `b`

So this gives a place to start.


Next let's make utility type to turn a string[] of param names into this intersection of strings.

type UnionToIntersection<U> = 
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never

type Path<Params extends string[]> = UnionToIntersection<
    {
        [K in keyof Params & number]: `${string}{${Params[K]}}${string}`
    }[number]
>

type TestAB = Path<['a', 'b']>
//   ^? `${string}a${string}` & `${string}b${string}`

const pathGood: Path<['id']> = 'path?id={id}' // fine
const pathGood2: Path<['id', 'name']> = 'path?myname={name}&id={id}' // fine
const pathBad: Path<['id']> = 'asd?bad={bad}' // error

The Path type here takes a tuple of params and maps over them creating the string type for each param. We then index that by number to turn that object into a union of its values. And lastly we use the fantastic UnionToIntersection from this answer to combine them all into the intersection type we need.


That's one path, but to do the whole object we need more types.

type Paths<
    Config extends { [name: string]: { params: string[] } }
> = {
    [K in keyof Config]: {
        path: Path<Config[K]['params']>
    }
}

This type maps over a config type and finds the keys and corresponding param types the final paths need to make.

With that we can make a schema type:

type MyPathsSchema = {
  foo: { params: ["id"] },
  bar: { params: ["id", 'name'] },
}

And use it in a function:

function defineConfig<
    MyPaths extends Paths<MyPathsSchema>
>(paths: MyPaths) {
  //...
}

And test it out:

defineConfig({
    foo: { path: 'foo?id={id}' },
    bar: { path: 'foo?name={name}&id={id}' }
}) // fine

defineConfig({
    foo: { path: 'foo?id={id}' },
    bar: { path: 'foo?name={name}' }
}) // Type '"foo?name={name}"' is not assignable to type
   //      '`${string}{id}${string}` & `${string}{name}${string}`'.

See Playground

Upvotes: 2

Related Questions