Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/strict-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'graphql-modules': minor
---

Add `defineProviders` to preserve type-safe provider inference for separately declared provider arrays and provider factories.

Inline providers now verify that `useValue` matches its `InjectionToken` or class token type, including union tokens. Explicit `Provider[]` annotations remain supported but opt out of this inference.

```ts
const ApiKey = new InjectionToken<string>('api-key')

createApplication({
modules: [],
providers: [{ provide: ApiKey, useValue: 'my-api-key' }]
})
```

Use `defineProviders` when providers are declared separately or returned from a provider factory:

```ts
const providers = defineProviders([{ provide: ApiKey, useValue: 'my-api-key' }])

createApplication({ modules: [], providers })

createApplication({
modules: [],
providers: () =>
defineProviders([{ provide: ApiKey, useValue: 'my-api-key' }])
})
```
7 changes: 4 additions & 3 deletions packages/graphql-modules/src/application/application.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { makeExecutableSchema } from '@graphql-tools/schema';
import {
Provider,
ReflectiveInjector,
onlySingletonProviders,
onlyOperationProviders,
Expand Down Expand Up @@ -58,9 +59,9 @@ export interface InternalAppContext {
* })
* ```
*/
export function createApplication(
applicationConfig: ApplicationConfig
): Application {
export function createApplication<
const TProviders extends Provider[] = Provider[],
>(applicationConfig: ApplicationConfig<TProviders>): Application {
function applicationFactory(cfg?: ApplicationConfig): Application {
const config = cfg || applicationConfig;
const providers =
Expand Down
7 changes: 5 additions & 2 deletions packages/graphql-modules/src/application/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ExecutionResult,
} from 'graphql';
import type { Provider, Injector } from '../di';
import type { ValidatedProviders } from '../di/providers';
import type { Resolvers, Module, MockedModule } from '../module/types';
import type { MiddlewareMap } from '../shared/middleware';
import type { ApolloRequestContext } from './apollo';
Expand Down Expand Up @@ -120,15 +121,17 @@ export interface OperationController {
* @api
* Application's configuration object. Represents the first argument of `createApplication` function.
*/
export interface ApplicationConfig {
export interface ApplicationConfig<TProviders extends Provider[] = Provider[]> {
/**
* A list of GraphQL Modules
*/
modules: Module[];
/**
* A list of Providers - read the ["Providers and Tokens"](./di/providers) chapter.
*/
providers?: Provider[] | (() => Provider[]);
providers?:
| ValidatedProviders<TProviders>
| (() => ValidatedProviders<TProviders>);
/**
* A map of middlewares - read the ["Middlewares"](./advanced/middlewares) chapter.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/graphql-modules/src/di/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
Factory,
FactoryProvider,
TypeProvider,
defineProviders,
ProviderOptions,
Scope,
onlySingletonProviders,
Expand Down
36 changes: 35 additions & 1 deletion packages/graphql-modules/src/di/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ export const Type = Function;

/// @ts-ignore
export class InjectionToken<T> {
constructor(private _desc: string) {}
private readonly _desc: string & { readonly __type?: T };

constructor(desc: string) {
this._desc = desc as string & { readonly __type?: T };
}

toString(): string {
return `InjectionToken ${this._desc}`;
Expand Down Expand Up @@ -54,6 +58,36 @@ export type Provider<T = any> =
| ClassProvider<T>
| FactoryProvider<T>;

type TokenValue<TToken> = TToken extends
| InjectionToken<infer TValue>
| Type<infer TValue>
| AbstractType<infer TValue>
? TValue
: never;

type ValidateValueProvider<TProvider> = TProvider extends {
provide: infer TToken;
useValue: infer TValue;
}
? [TValue] extends [TokenValue<TToken>]
? TProvider
: never
: TProvider;

export type ValidatedProviders<TProviders extends readonly Provider[]> =
TProviders & {
[TIndex in keyof TProviders]: ValidateValueProvider<TProviders[TIndex]>;
};

/**
* Preserves provider inference for separately declared provider arrays.
*/
export function defineProviders<const TProviders extends Provider[]>(
providers: ValidatedProviders<TProviders>
): TProviders {
return providers;
}

export interface ProviderOptions {
scope?: Scope;
executionContextIn?: Array<string | symbol>;
Expand Down
1 change: 1 addition & 0 deletions packages/graphql-modules/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
ClassProvider,
ValueProvider,
TypeProvider,
defineProviders,
forwardRef,
InjectionToken,
Scope,
Expand Down
5 changes: 4 additions & 1 deletion packages/graphql-modules/src/module/module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { moduleFactory } from './factory';
import { Provider } from '../di';
import { ModuleConfig } from './types';

/**
Expand All @@ -21,6 +22,8 @@ import { ModuleConfig } from './types';
* });
* ```
*/
export function createModule(config: ModuleConfig) {
export function createModule<const TProviders extends Provider[] = Provider[]>(
config: ModuleConfig<TProviders>
) {
return moduleFactory(config);
}
7 changes: 5 additions & 2 deletions packages/graphql-modules/src/module/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ModuleFactory } from './factory';
import { ID, Plural } from '../shared/types';
import { ModuleMetadata } from './metadata';
import { Provider } from '../di';
import type { ValidatedProviders } from '../di/providers';
import { MiddlewareMap } from '../shared/middleware';

export type TypeDefs = Plural<DocumentNode>;
Expand All @@ -12,7 +13,7 @@ export type Resolvers = Plural<Record<string, any>>;
* @api
* Module's configuration object. Represents the first argument of `createModule` function.
*/
export interface ModuleConfig {
export interface ModuleConfig<TProviders extends Provider[] = Provider[]> {
/**
* Unique identifier of a module
*/
Expand All @@ -36,7 +37,9 @@ export interface ModuleConfig {
/**
* A list of Providers - read the ["Providers and Tokens"](./di/providers) chapter.
*/
providers?: Provider[] | (() => Provider[]);
providers?:
| ValidatedProviders<TProviders>
| (() => ValidatedProviders<TProviders>);
}

export interface Module {
Expand Down
6 changes: 4 additions & 2 deletions packages/graphql-modules/src/testing/test-injector.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Injector, ReflectiveInjector } from '../di/injector';
import { Provider, TypeProvider } from '../di/providers';
import { Provider, TypeProvider, ValidatedProviders } from '../di/providers';
import { CONTEXT } from '../application/tokens';
import { readInjectableMetadata } from '../di/metadata';

export function testInjector(providers: Provider[]): Injector {
export function testInjector<const TProviders extends Provider[] = Provider[]>(
providers: ValidatedProviders<TProviders>
): Injector {
const resolvedProviders = ReflectiveInjector.resolve([
{ provide: CONTEXT, useValue: {} },
...providers,
Expand Down
91 changes: 91 additions & 0 deletions packages/graphql-modules/tests/provider-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {
createApplication,
createModule,
defineProviders,
InjectionToken,
Provider,
testkit,
} from 'graphql-modules';

const StringToken = new InjectionToken<string>('string');
declare const StringOrNumberToken:
| InjectionToken<string>
| InjectionToken<number>;

// @ts-expect-error injection tokens retain their value type
const incorrectToken: InjectionToken<number> = StringToken;

void incorrectToken;

class Service {
readonly kind = 'service';
}

class OtherService {
readonly kind = 'other-service';
}

createApplication({
modules: [],
providers: [{ provide: StringToken, useValue: 'value' }],
});

createApplication({
modules: [],
// @ts-expect-error useValue must match the token value type
providers: [{ provide: StringToken, useValue: 123 }],
});

createApplication({
modules: [],
// @ts-expect-error useValue must be valid for every token in the union
providers: [{ provide: StringOrNumberToken, useValue: false }],
});

createApplication({
modules: [],
providers: [{ provide: Service, useValue: new Service() }],
});

createApplication({
modules: [],
// @ts-expect-error useValue must match the class token type
providers: [{ provide: Service, useValue: new OtherService() }],
});

const widenedProviders: Provider[] = [{ provide: StringToken, useValue: 123 }];

createApplication({ modules: [], providers: widenedProviders });

const declaredProviders = defineProviders([
{ provide: StringToken, useValue: 'value' },
]);

createApplication({ modules: [], providers: declaredProviders });

// @ts-expect-error useValue must match the token value type
defineProviders([{ provide: StringToken, useValue: 123 }]);

createApplication({
modules: [],
providers: () =>
defineProviders([{ provide: StringToken, useValue: 'value' }]),
});

createModule({
id: 'provider-types',
typeDefs: [],
providers: [{ provide: StringToken, useValue: 'value' }],
});

createModule({
id: 'invalid-provider-types',
typeDefs: [],
// @ts-expect-error useValue must match the token value type
providers: [{ provide: StringToken, useValue: 123 }],
});

testkit.testInjector([{ provide: StringToken, useValue: 'value' }]);

// @ts-expect-error useValue must match the token value type
testkit.testInjector([{ provide: StringToken, useValue: 123 }]);
28 changes: 28 additions & 0 deletions website/src/content/di/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ The building blocks of DI are **Provider**(s) and **InjectionToken**(s).

Dependency Injection is an abstraction over actual objects and values.

<Callout>
In direct, inline provider arrays, GraphQL Modules checks that a `useValue`
matches its typed `InjectionToken` or class token. Explicitly widening an
array to `Provider[]` erases that information. Use `defineProviders` for a
separately declared array or provider factory.
</Callout>

```ts
const ApiKey = new InjectionToken<string>('api-key')

createApplication({
modules: [],
providers: [
// TypeScript error: number is not assignable to string.
{ provide: ApiKey, useValue: 123 }
]
})
```

```ts
const providers = defineProviders([{ provide: ApiKey, useValue: 'my-api-key' }])

createApplication({
modules: [],
providers
})
```

There are three kinds of providers:

- [Class provider](#class) - creates an instance of a class
Expand Down
Loading