angular / angular

Deliver web apps with confidence šŸš€
https://angular.dev
MIT License
95.16k stars 24.96k forks source link

[Feature] Introduce Type-safe Token Providing #55555

Open Char2sGu opened 2 months ago

Char2sGu commented 2 months ago

Which @angular/* package(s) are relevant/related to the feature request?

core

Description

The current object-literal-based providing syntax cannot ensure:

  1. The type of the provided value is compatible with the token.
  2. The multi: true option is not missing or mistakenly added

For libraries, in order to ensure type-safe providing, it has been a common pattern to create provideXxx helpers. However, in daily application development, the providng experience is still non-type-safe.

Since all the ProviderTokens carry type information, it is possible to create framework-level providing helpers to ensure type safety.

I have created such helper functions and fully adopted it in all my projects.
I would like to create a PR to introduce these helpers to @angular/core to enable a safer providing experience for all Angular developers.

Please see below for details.

Proposed solution

Signatures

function provide<T, U extends T>(config: ProvideConfig<T, U>): Provider;
function provideMulti<T>(config: ProvideMultiConfig<T>): Provider;

Usage

provide({ token: TitleStrategy, useClass: AppTitleStrategy });
provide({ token: TitleStrategy, useExisting: AppTitleStrategy });
// `AppTitleStrategy` have to be assignable to `TitleStrategy`.

declare const SNACKBAR_CONFIG: InjectionToken<SnackbarConfig>;
provide({ token: SNACKBAR_CONFIG, useValue: { ... } });
// Type of `useValue` is inferred as `SnackbarConfig`, enabling autocomplete.

declare const BREAKPOINTS: InjectionToken<BreakpointMap>
provide({ token: BREAKPOINTS, useFactory: (observer = inject(BreakpointObserver)) => observer.observe() });
// Return type of the factory has to match `BreakpointMap`

provide({ token: APP_INITIALIZER, useFactory: () => () => {} });
// Type error. APP_INITIALIZER should be an array of functions, but the return type of the factory is a single function.
// Type '() => void' is not assignable to type 'readonly (() => void | Observable<unknown> | Promise<unknown>)[]'
provideMulti({ token: TitleStrategy, useClass: AppTitleStrategy });
// Type error. TitleStrategy does not support multi providing.
// Argument of type '{ token: typeof TitleStrategy; useClass: typeof AppTitleStrategy; }' is not assignable to parameter of type 'never'.

provideMulti({ token: APP_INITIALIZER, useFactory: () => () => {} });
// Legit. The return type of the factory matches the item type of APP_INITIALIZER

Implementation

export function provide<T, U extends T>(config: ProvideConfig<T, U>): Provider {
  if ('useValue' in config)
    return { provide: config.token, useValue: config.useValue };
  if ('useFactory' in config)
    return { provide: config.token, useFactory: config.useFactory };
  if ('useClass' in config)
    return { provide: config.token, useClass: config.useClass };
  if ('useExisting' in config)
    return { provide: config.token, useExisting: config.useExisting };
  throw new Error('Invalid config'); // impossible to happen
}

export function provideMulti<T>(config: ProvideMultiConfig<T>): Provider {
  return {
    ...provide(config as ProvideConfig<any>),
    multi: true,
  } as Provider;
}
export type ProvideConfig<T, U extends T = T> = ProvideConfigToken<T> &
  ProvideConfigUse<U>;

export type ProvideMultiConfig<T> = ProvideConfigToken<T> &
  (T extends readonly (infer I)[] ? ProvideConfigUse<I> : never);

export interface ProvideConfigToken<T> {
  token: ProviderToken<T>;
}

export type ProvideConfigUse<T> =
  | ProvideConfigUseValue<T>
  | ProvideConfigUseFactory<T>
  | ProvideConfigUseClass<T>
  | ProvideConfigUseExisting<T>;
export interface ProvideConfigUseValue<T> {
  useValue: T;
}
export interface ProvideConfigUseFactory<T> {
  useFactory: () => T;
}
export interface ProvideConfigUseClass<T> {
  useClass: Type<T>;
}
export interface ProvideConfigUseExisting<T> {
  useExisting: ProviderToken<T>;
}

Alternatives considered

N/A

JeanMeche commented 2 months ago

Hi, We related issues on that topic we #28778 and #51675. Right now, InjectionToken does not provide info on if the token should be multi or not.

Char2sGu commented 2 months ago

I see. I think it would still be nice to add provide().

alxhub commented 2 months ago

This has been on my mind too, as a potential way to add incremental type safety to DI.

alxhub commented 2 months ago

Also, šŸ‘ nice issue number!

Char2sGu commented 2 months ago

lol I didn't notice the number.

Char2sGu commented 2 months ago

Do you expect me to create a PR? What am I supposed to do next?

SeregPie commented 2 months ago

very similar to my proposal.

55054

EricPoul commented 3 weeks ago

Definitely would be nice to have such API. But I guess, if we add such API, it has to be nicer than just a type-safe covering function. I like @SeregPie way of providing tokens or we should simplify the example in this feature-request. I feel it excessive to write token since if we provide, we provide some token anyway.

rough examples

  1. Pros: One import.
    provide(token: T, options: Omit<Provider, 'provide'>): Provider; // with proper type in options
  2. Pros: Descriptive.
    provideAsValue(token: T, value: TokenValueType, options: { multi: true }): Provider;
    provideAsClass(token: T, class: TokenValueType, options: { multi: true }): Provider;
    provideAsExisting(token: T, value: TokenValueType, options: { multi: true }): Provider;
    provideAsFactory(token: T, factory: () => TokenValueType, options: { multi: true }): Provider;
Char2sGu commented 3 weeks ago

I feel it more semantical correct to use provideUseValue or provideFromValue, since it is the "token" that you are providing.

Besides, I think this syntax might result in not-that-good-looking code formatting results:

Your proposal:

provideUseClass(MyVeryLongClass, () => MyVeryLongClassImplementation, { multi: true });
// can be formmatted into:
provideUseClass(MyVeryLongClass, () => MyVeryLongClassImplementation, {
  multi: true,
});

Original proposal:

provideMulti({
  token: MyVeryLongClass,
  useClass: MyVeryLongClassImplementation,
})
EricPoul commented 3 weeks ago

For me, both variants are read kind of the same.

  1. Provide token as value(class, factory)
  2. Provide token using value(class, factory)

I still don't like having property token in options. I'd rather deal with formatting since most of the time multi is used with factory or value and with factory I will have provide function on a few lines of code anyway.

Anyway, I just want to have this API out of the box.