Narrow string literal type dynamically based on code

Sorry if the title is not accurate, It’s kind of a weird question.

I have the following code:

enum LogType {
  file = "file",
  http = "http",
  database = "database"
}

interface LoggerDetails {
  // ... bunch of properties
  provider: "datadog" | "log4j" | "newrelic"
}

interface Logger {
  type: LogType;
  provider: LoggerDetails["provider"];
}


interface NetworkLogger extends Logger {
  type: LogType.http,
  provider: "datadog"
}

interface FileLogger extends Logger {
  type: LogType.file,
  provider: "log4j"
}

type LoggerKey = `${LogType}.${Logger["provider"]}`;

// type LegalLoggerKeys = Extract<LoggerKey, "http.datadog" | "file.log4j">;// <-- This works, but hard coded. Also, it requires us to write it down for every new logger interface
type LegalLoggerKeys = unknown; // ?? 

// should only accept 'http.datadog' and 'file.log4j' as keys using the interfaces that extends Logger 
const record: Record<LegalLoggerKeys, (message: string) => void> = {

}

My goal is to create LegalLoggerKeys using the interfaces FileLogger, NetworkLogger, or any other implementation. as you can see my commented code, I was able to do it with hard coded string literals.

Is it possible to create the same thing using the interfaces?
Playground Link

>Solution :

The compiler won’t be able to automatically discover all interfaces declared to extend Logger, so you’ll need to maintain such a list yourself manually. One way to do that is with a union:

type KnownLoggers = NetworkLogger | FileLogger // <-- maintain this manually

Once you have such a union, you can manipulate it to generate the union of acceptable keys. If you have a single logger type L that extends Logger, you can compute the allowed key via the template literal type:

type LoggerKey<L extends Logger> = `${L['type']}.${L['provider']}`;

type NetworkLoggerKey = LoggerKey<NetworkLogger>;
// type NetworkLoggerKey = "http.datadog"

But if you have a union of logger types, this won’t behave the way you want, since each piece of that concatenated string will be split into unions separately:

type KnownLoggerKeys = LoggerKey<KnownLoggers>
// type KnownLoggerKeys = "file.datadog" | "file.log4j" | "http.datadog" | "http.log4j" 👎

Oops.


So we will need to tweak the definition of LoggerKey so that it distributes its operation across unions in its input. We can do this by expressing it as a distributive conditional type:

type LoggerKey<L extends Logger> =
  L extends Logger ? `${L['type']}.${L['provider']}` : never;

Note that the check L extends Logger ? isn’t really useful as a check; the compiler already knows that L is constrained to Logger. But it serves as a directive to split L up into its union members before evaluation and then to re-unite into a new union afterward.

And so now we have:

type KnownLoggerKeys = LoggerKey<KnownLoggers>
// type KnownLoggerKeys = "http.datadog" | "file.log4j" 👍

as desired.

Playground link to code

Leave a Reply