Search code examples
typescriptgenerics

Typescript generic initially infers then is set


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.


Solution

  • 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
    

    Playground link to code