config icon indicating copy to clipboard operation
config copied to clipboard

Readonly ConfigType

Open yura2100 opened this issue 2 years ago • 2 comments

Is there an existing issue that is already proposing this?

  • [X] I have searched the existing issues

Is your feature request related to a problem? Please describe it

Using Configuration namespaces is pretty powerful as it enhances type safety and reduces amount of boilerplate code. However, configuration object is a mutable singleton shared across the whole application. There are no limitations for mutating configuration object in one service, which may affect other services.

Describe the solution you'd like

Adding readonly config type directly to framework could prevent config mutation errors on compile time and reduce amount of boilerplate code which is similar across projects.

API

There are 3 possible APIs for this:

  1. Making ConfigType infer readonly config type by default. I think it is the best solution, however it introduces a BREAKING CHANGE

  2. Changing ConfigType signature:

    // Before
    type ConfigType<T extends (...args: any) => any> = ...
    
    // After
    type ConfigType<T extends (...args: any) => any, IsReadonly extends boolean = false> = ...
    

    This solution doesn't affect existing codebases, however it adds possibility to make ConfigType to infer readonly config type. The API is similar to WasValidated param in ConfigService. I don't like this solution as adding extra param is a bit confusing in my opinion.

  3. Introducing a new ReadonlyConfigType type. In my opinion new type is more expressive than the second solution.

Implementation

There are 2 ways to implement readonly config type:

  1. Naive ReadonlyDeep. This implementation is limited as it doesn't narrow arrays for tuples for example and doesn't work with arrays of objects.

    type ReadonlyDeep<T> = {
      readonly [P in keyof T]: T[P] extends Record<string, unknown>
        ? ReadonlyDeep<T[P]>
        : T[P];
    };
    
  2. Recursive ReadonlyDeep see type-fest ReadonlyDeep implementation.

What is the motivation / use case for changing the behavior?

.env

PORT=3000

src/config.ts

...

export default registerAs("config", () => ({
  port: Number(process.env.PORT),
}));

src/app.service.ts

...

@Injectable()
export class AppService {
  constructor(
    @Inject(config.KEY) private readonly configuration: ConfigType<typeof config>
  ) {}

  mutateConfig() {
    // port now is globally equal to 3001
    // No ts errors
    this.configuration.port = 3001;
  }
}

Adding as const to config registration doesn't solve the issue as ConfigType still infers as a mutable config type:

export default registerAs("config", () => ({
  port: Number(process.env.PORT),
} as const));

For now the only way to address this issue is to manually narrow inferred config type to readonly:

type ReadonlyDeep<T> = ...

type Config = ReadonlyDeep<ConfigType<typeof config>>;

yura2100 avatar Jul 19 '23 22:07 yura2100

Making ConfigType infer readonly config type by default. I think it is the best solution, however it introduces a BREAKING CHANGE

Sounds great! Would you like to create a PR for this issue? We can wait with merging it till the next major release (due to a breaking change)

kamilmysliwiec avatar Jul 20 '23 06:07 kamilmysliwiec

Sounds great! Would you like to create a PR for this issue? We can wait with merging it till the next major release (due to a breaking change)

Sure!

yura2100 avatar Jul 20 '23 11:07 yura2100