So I have this TS code:
type DefaultLevel = 'info' | 'error'; // Default log levels
interface LoggerOptions<CustomLevels extends string, Level extends CustomLevels | DefaultLevel> {
customLevels?: Record<CustomLevels, number>
level: Level
}
class Logger<CustomLevel extends string = '', Level extends CustomLevel | DefaultLevel = CustomLevel | DefaultLevel> {
constructor(options?: LoggerOptions<CustomLevel, Level>) {
// Initialization based on options
}
log(level: Level, message: string) {
// Log message
}
}
// Usage
const logger = new Logger({
customLevels: {
debug: 1,
trace: 2,
},
level: 'debug'
});
logger.log('debug', '')
I first create a global DefaultLevel
union
type
for my logger. Then I have a LoggerOption
interface that has two generics CustomLevels
and Level
. CustomLevels
allows me to create a custom level for my logger keeping everything type safe, while Level extends CustomLevels | DefaultLevels
is needed to set the default level for my default logger. Doing level: CustomLevels
did not work, hence the second generic.
My question arises in the log function of the class.
If I try to call logger.log('trace') I get a type error saying: 'Argument of type '"trace"' is not assignable to parameter of type '"debug"'.'
Hence I imagine that the LoggerOptions
interface is setting the Level
type value.
My question is, why within the LoggerOptions
the level can be of type 'info' | 'error' | 'debug' | 'trace'
(desired behavior), but later in the code, variables of type Level
can only be of the string type set within the LoggerOptions
?
I know that within the log() method I could do something like this:
log(level: CustomLevel | DefaultLevel, message: string) {
// Log message
}
But using the Level
type would've been certainly much neater.
It doesn't look like you really want Logger
to be generic in Level
at all. That Level
type will be inferred by the level
property, which will almost certainly be narrower than CustomLevels | DefaultLevel
. If you want Level
to be CustomLevels | DefaultLevel
then use that type explicitly instead of trying to save it to another type parameter.
Indeed, your inclusion of that as a type parameter seems to have been meant just as a way to prevent CustomLevels
from being inferred from the level
property of the constructor input. You want
new Logger({ customLevels: { a: 1, b: 2 }, level: "c" });
to be an error, as opposed to inferring CustomLevels
as "a" | "b" | "c"
. An additional type parameter can serve that purpose, but the "correct" tool for that job is to use the intrinsic NoInfer<T>
utility type:
interface LoggerOptions<C extends string> {
customLevels?: Record<C, number>
level: NoInfer<C>
}
Now Logger
only has to be generic in one type parameter:
class Logger<C extends string> {
constructor(options?: LoggerOptions<C>) { }
log(level: C | DefaultLevel, message: string) { }
}
You get the error you wanted:
new Logger({ customLevels: { a: 1, b: 2 }, level: "c" }) // error
As well as the proper behavior for log()
:
const logger = new Logger({
customLevels: {
debug: 1,
trace: 2,
},
level: 'debug'
});
logger.log('trace', ''); // okay