Typescript template literal types circular constraint

Viewed 175

I'm struggling to find a way of displaying a type parameter in an error message. The idea is to prevent passing dependencies that were already injected, and check this during compile time.

This is how I solved it:

export type TCons<T> = new (...args: any[]) => T

export interface Has<K extends string, T> {
  get: (k: K, v: TCons<T>) => T
}

type CombineExclusive<Host, Key extends string, Provider> = <
  P extends Host extends Has<Key, P> ? `${Key} already exists` : Provider
>(
  provider: P
) => Host extends Has<Key, P> ? never : Host & Has<Key, P>

export interface Application {
  withSearchProvider: CombineExclusive<this, "SearchProvider", SearchProvider>
}

If used like this:

const liveApplication = (app: Application) =>
  app
    .withSearchProvider(new A())
    .withSearchProvider(new B())
    .withSearchProvider(new A())

You will get an error on the last line which looks like this: SearchProvider already exists. I want to improve it a little bit: SearchProvider A already exists and here is where I started to struggle:

type CombineExclusive<Host, Key extends string, Provider> = <
  P extends Host extends Has<Key, P> ? `${Key} ${P} already exists` : Provider
>(
  provider: P
) => Host extends Has<Key, P> ? never : Host & Has<Key, P>

I cannot reference P in the template literal since it creates a circular constraint. Another way might be to reference "original" P, but I don't know how, or whether it is possible. So, my task is to create a type constraint which error message displays parameter type P. Any ideas?

Link to ts playground: playground

1 Answers

TypeScript sometimes accepts circular references and other times does not. If you have a generic function type and can't get a circular reference to be accepted inside a type parameter's constraint, you can sometimes move the reference out of the constraint and into a conditionally typed function parameter. That is, from something like this:

function orig<T extends F<T>>(param: T) { } // error, circular constraint

to something like this:

function fixed<T>(param: T extends F<T> ? T : F<T>) { } // okay

It's a bit of a weird construction to write T extends F<T> ? T : F<T>, but generally speaking the compiler will infer T to be the type of param. Therefore if T extends F<T> as desired, the function will look like function fixed<T>(param: T) {} and there will be no error. On the other hand if T extends F<T> is not satisfied, then the function will look like function fixed<T>(param: F<T>) and since param is of type T but not F<T>, you'll get an error very similar to the one you get when you violate a generic constraint.


In your example, this could be changed to something like:

type CombineExclusive<Host, Key extends string, Provider> = <
  P extends Provider
  >(provider: P extends (Host extends Has<Key, P> ? never : unknown) ? P :
    `${Key} of type '${Extract<P, { type: string }>['type']}' already exists`
) => Host extends Has<Key, P> ? never : Host & Has<Key, P>

I changed it around a little, but it has the same effect; if Host extends Has<Key, P> is true, the this becomes P extends unknown ? P : `...` which becomes P and the call will succeed. If Host extends Has<Key, P> is false, then this becomes P extends never ? P : `...` which becomes `...` and the call will fail, with the template literal as part of the error message.

Also note that you cannot serialize P to a string via `${P}` because P is not a string/number/boolean/bigint (as required by ms/TS#40336). So I'm taking P, which should have a string-valued type property, and putting that in the message.

Let's see it in action:

const liveApplication = (app: Application) =>
  app
    .withSearchProvider(new A())
    .withSearchProvider(new B())
    .withSearchProvider(new A()) // error 
// -------------------> ~~~~~~~
// Argument of type 'A' is not assignable to parameter of type 
// '"SearchProvider of type 'a' already exists"'.(2345)

Looks good!

Playground link to code

Related