Typescript incorrect type union in instantiating new class object

52 views Asked by At

I have a Logger class in typescript that takes an optional options parameter in its constructor with a class generic C that represent custom levels. This type is used within the class for methods such as log(level: Level<C>). Now, I want an updateConfig method that returns a new Logger instance where its C parameter is a union of the 'father's' custom levels + those defined in the new options object.

I have:

type DefaultLevel = 'info' | 'error'; // Default log levels

type Level<C extends string> = DefaultLevel | C
type LevelWithSilence<C extends string> = Level<C> | 'silence'

interface LoggerOptions<C extends string> {
  customLevels?: Record<C, number>
  level: LevelWithSilence<NoInfer<C>>
}

interface UpdatedLoggerOptions<C extends string, L extends string> {
  customLevels?: Record<L, number>
  level: LevelWithSilence<NoInfer<C | L >>
}

class Logger<C extends string = ''> {

  options?: LoggerOptions<C>

  constructor(options?: LoggerOptions<C>) {
    this.options = options
  }

  log(level: Level<C>, message: string) {
    // Log message
  }

  updateConfig<L extends string = never>(options?: UpdatedLoggerOptions<C, L>) {
    
    if(!this.options) return new Logger (options as LoggerOptions<C>)

    if(!options) return this

    return new Logger<C | L>({
      customLevels: {
        ...this.options.customLevels as Record<C, number>,
        ...options.customLevels as Record<L, number>
      },
      level: options.level
    } as LoggerOptions<C | L>)
  }
}

// Usage
const logger = new Logger({
  customLevels: {
    debug: 1,
    trace: 2,
  },
  level: 'error'
});

logger.log('debug', '')

const newLogger = logger.updateConfig({
  customLevels: {
    test: 3,
  },
  level: 'test'
})

newLogger.log('debug', '') // Works
newLogger.log('test', '') // Should work

Everything appears to be typesafe apart from the log method. newLogger.log('debug', '') works, indicating that it clearly extracted the father's custom levels. On the other hand, newLogger.log('test', '') gives me error: Argument of type '"test"' is not assignable to parameter of type 'Level<"debug" | "trace">'. indicating that it failed 'extrapolating' the custom levels from the options object.

Furthermore, upon hovering over the logger object, i get as a type: const logger: Logger<"debug" | "trace">, while hovering over newLogger I get const newLogger: Logger<"debug" | "trace"> | Logger<"debug" | "trace" | "test">.

How can I achieve my desired behavior?

2

There are 2 answers

0
jcalz On BEST ANSWER

Given the question as currently stated, the only issue is that you need the return type of updateConfig to be Logger<C | L> so that the returned value will accept things from both C and L:

updateConfig<L extends string = never>(
  options?: UpdatedLoggerOptions<C, L>
): Logger<C | L> { ⋯ }

The implementation will need type assertions or the like, so that you can convince the compiler that you are actually returning a Logger<C | L>, since it won't be able to understand the case analysis for generics that says, for example, "if options is missing then this is already a Logger<C | L> because we expect L to be never." So, when necessary, double check that your assumption about the type is correct (or unlikely to be violated in practice) and then assert:

updateConfig<L extends string = never>(options?: UpdatedLoggerOptions<C, L>): Logger<C | L> {

  if (!this.options) return new Logger(options as LoggerOptions<C | L>)
  // ----------------------------------------> ^^^^^^^^^^^^^^^^^^^^^^^

  if (!options) return this as Logger<C | L>
  // ---------------------> ^^^^^^^^^^^^^^^^

  return new Logger({
    customLevels: {
      ...this.options.customLevels as Record<C, number>,
      // ------------------------> ^^^^^^^^^^^^^^^^^^^^
      ...options.customLevels as Record<L, number>
      // -------------------> ^^^^^^^^^^^^^^^^^^^^
    },
    level: options.level
  })
}

Those assertions make things compile without error. Let's make sure you can use it as desired:

const logger = new Logger({
  customLevels: {
    debug: 1,
    trace: 2,
  },
  level: 'error'
});

logger.log('debug', '')

const newLogger = logger.updateConfig({
  customLevels: {
    test: 3,
  },
  level: 'test'
})

newLogger.log('debug', '') // Works
newLogger.log('test', '') // Works

Looks good.

Playground link to code

0
Marek Kapusta-Ognicki On

Well, long story short, constructor is privileged in this unjust world of OOP, that when it's called, there is no real class instantiated. So when you call:

    class Foo<T extends string = '', V extends string = ''> {
       constructor(opts?: T) {}
    }
    
    // there is no instance of `Foo` existing, types are inferred from non-existent
    
    const foo = new Foo('abc');
    
    // to existing - at this point constructor was able to infer the desired
    // type of T, and because there was nothing to work the type V from, this 
    // type couldn't have been inferred from anything - therefore was set to 
    // default

If it was implemented otherwise, that'd be a serious break of type-safety, see what would happen if you did:


    type DefaultLevel = 'info' | 'error';
    
    type Level<C extends string> = DefaultLevel | C
    type LevelWithSilence<C extends string> = Level<C> | 'silence'
    
    interface LoggerOptions<C extends string> {
      customLevels?: Record<C, number>
      level: LevelWithSilence<NoInfer<C>>
    } 
    
    class Logger<C extends string = '', V extends string = ''> {
    
      constructor(options?: LoggerOptions<C>) { // this is equivalent to setting also V without specified value
        // Initialization based on options
      }
    
      log(level: Level<C>, message: string) {
        // Log message
      }
    
      setPrimaryConfig(config: LoggerOptions<C>) {}
    
      setSecondaryConfig(config: LoggerOptions<V>) {}

}

why would setPrimaryConfig be forbidden to mutate C, whereas V - not? These are identical functions. Why should one be allowed to mutate type of the class, whereas another one - not? I mean, the concept of type-safety has been coined to ensure, well, type-safety - so once instantiated object doesn't get its type mutated. And attempt to mutate generic type V on Foo<T..., V...> after Foo is constructed is, well, making a type of the same object fluctuating.

Now, assume this:

    class Foo<T extends string = '', V extends string = ''> {
        constructor(opts?: T) {}
        private secondaryOpts?: V;
        setSecondary(opts: V) {
           this.secondaryOpts = opts;
        }
    
        get secondaryOptsOrFalse (): V extends '' ? false : V {
            return this.secondaryOpts ?? false;
        }
    
    }
    
    const foo = new Foo('abc');
    const firstForSecondaryOpts = foo.secondaryOpts // type false;
    foo.setSecondary('def');
    // if types were mutable, we'd have
    const secondForSecondaryOpts = foo.secondaryOpts // type is 'def';

As a result, we won't be able even to run:

    firstForSecondaryOpts === secondForSecondaryOpts

because there would be no overlap in types between first... (false) and second... (string)


Besides, constructor's role is to construct, build. Constructor works on a plan, a blueprint, a guide to build some object. If other methods were able to override constructor's blueprint, then any type would gain the potential to mutate over time. Which makes the whole idea of types in TS quite pointless :)

And there are a lot of other examples here. Like, if you can change the generic type of a class after it's constructed, any methods operating on that type before and after setting secondary config would have to switch their operational logic to reflect changes in types.


Besides! You defined LevelWithSilence<C extends string> = Level<C> | 'silence', so even if anything else might work/not work, we have the first immutability here.


So, first things last. Reconsider what you intend to achieve, and if you really need that, and what for. Sometimes, quite often even, too much flexibility on types kills the whole idea of immutability.