Skip to content

Commit

Permalink
Merge pull request #255 from 19majkel94/generic-types
Browse files Browse the repository at this point in the history
Generic types support
  • Loading branch information
MichalLytek authored Feb 21, 2019
2 parents 37d499c + 1871aae commit 084ca96
Show file tree
Hide file tree
Showing 21 changed files with 836 additions and 26 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- **Breaking Change**: change the default `PrintSchemaOptions` option `commentDescriptions` to false (no more `#` comments in SDL)
- add support for passing `PrintSchemaOptions` in `buildSchema.emitSchemaFile` (e.g. `commentDescriptions: true` to restore previous behavior)
- add `buildTypeDefsAndResolvers` utils function for generating apollo-like `typeDefs` and `resolvers` pair (#233)
- add support for generic types (#255)

## Fixes
- fix calling return type getter function `@Field(type => Foo)` before finishing module evaluation (allow for extending circular classes using `require`)
Expand Down
1 change: 1 addition & 0 deletions dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ require("ts-node/register/transpile-only");
// require("./examples/authorization/index.ts");
// require("./examples/automatic-validation/index.ts");
// require("./examples/enums-and-unions/index.ts");
// require("./examples/generic-types/index.ts");
// require("./examples/interfaces-inheritance/index.ts");
// require("./examples/middlewares/index.ts");
// require("./examples/redis-subscriptions/index.ts");
Expand Down
3 changes: 2 additions & 1 deletion docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ All examples has a `examples.gql` file with sample queries/mutations/subscriptio

## Advanced
- [Enums and unions](https://github.com/19majkel94/type-graphql/tree/master/examples/enums-and-unions)
- [Interfaces and types inheritance](https://github.com/19majkel94/type-graphql/tree/master/examples/interfaces-inheritance)
- [Subscriptions (simple)](https://github.com/19majkel94/type-graphql/tree/master/examples/simple-subscriptions)
- [Subscriptions (using Redis)](https://github.com/19majkel94/type-graphql/tree/master/examples/redis-subscriptions)
- [Interfaces and types inheritance](https://github.com/19majkel94/type-graphql/tree/master/examples/interfaces-inheritance)
- [Resolvers inheritance](https://github.com/19majkel94/type-graphql/tree/master/examples/resolvers-inheritance)
- [Generic types](https://github.com/19majkel94/type-graphql/tree/master/examples/generic-types)

## Features usage
- [Dependency injection (IoC container)](https://github.com/19majkel94/type-graphql/tree/master/examples/using-container)
Expand Down
130 changes: 130 additions & 0 deletions docs/generic-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
---
title: Generic types
---

[Types inheritance](inheritance.md) is a great way to reduce the code duplication by extracting common fields to the base class. But in some cases, the strict set of fields is not enough because we might need to declare the types of some fields in a more flexible way, like a type parameter (e.g. `items: T[]` in case of a pagination).

Hence TypeGraphQL has also support for describing generic GraphQL types.

## How to?

Unfortunately, the limited reflection capabilities of TypeScript doesn't allow for combining decorator with the standard generic classes. To achieve a behavior like the generic types, we will use the same class-creator pattern like the one described in [resolvers inheritance](inheritance.md) docs.

So we will start by defining a `PaginatedResponse` function that creates and returns a `PaginatedResponseClass`:

```typescript
export default function PaginatedResponse() {
abstract class PaginatedResponseClass {
// ...
}
return PaginatedResponseClass;
}
```

To achieve a generic-like behavior, the function has to be generic and take some runtime argument related to the type parameter:

```typescript
export default function PaginatedResponse<TItem>(TItemClass: ClassType<TItem>) {
abstract class PaginatedResponseClass {
// ...
}
return PaginatedResponseClass;
}
```

Then, we need to add proper decorators to the class - it might be `@ObjectType`, `@InterfaceType` or `@InputType`.
It also should have set `isAbstract: true` to prevent registering in schema:

```typescript
export default function PaginatedResponse<TItem>(TItemClass: ClassType<TItem>) {
@ObjectType({ isAbstract: true })
abstract class PaginatedResponseClass {
// ...
}
return PaginatedResponseClass;
}
```

After that, we can add fields like in a normal class but using the generic type and parameters:

```typescript
export default function PaginatedResponse<TItem>(TItemClass: ClassType<TItem>) {
// `isAbstract` decorator option is mandatory to prevent registering in schema
@ObjectType({ isAbstract: true })
abstract class PaginatedResponseClass {
// here we use the runtime argument
@Field(type => [TItemClass])
// and here the generic type
items: TItem[];

@Field(type => Int)
total: number;

@Field()
hasMore: boolean;
}
return PaginatedResponseClass;
}
```

Finally, we can use the generic function factory to create a dedicated type class:

```typescript
@ObjectType()
class PaginatedUserResponse extends PaginatedResponse(User) {
// we can freely add more fields or overwrite the existing one's types
@Field(type => [String])
otherInfo: string[];
}
```

And then use it in our resolvers:

```typescript
@Resolver()
class UserResolver {
@Query()
users(): PaginatedUserResponse {
const response = new PaginatedUserResponse();
// here is your custom business logic,
// depending on underlying data source and libraries
return response;
}
}
```

You can also create a generic class without using `isAbstract` option or `abstract` keyword.
But types created with this kind of factory will be registered in schema, so it's not recommended to use this way to extend the types for adding some more fields.

To avoid generating schema errors about duplicated `PaginatedResponseClass` type names, you need to provide your own, unique, generated type name:

```typescript
export default function PaginatedResponse<TItem>(TItemClass: ClassType<TItem>) {
// instead of `isAbstract`, you have to provide a unique type name used in schema
@ObjectType({ name: `Paginated${TItemClass.name}Response` })
class PaginatedResponseClass {
// the same fields as in the earlier code snippet
}
return PaginatedResponseClass;
}
```

Then, you can store the generated class in a variable. To be able to use it both as a runtime object and a type, you also have to create a type for this new class:

```typescript
const PaginatedUserResponse = PaginatedResponse(User);
type PaginatedUserResponse = InstanceType<typeof PaginatedUserResponse>;

@Resolver()
class UserResolver {
// remember to provide a runtime type argument to the decorator
@Query(returns => PaginatedUserResponse)
users(): PaginatedUserResponse {
// the same implementation as in the earlier code snippet
}
}
```

## Examples

More advanced usage example of a generic types feature you can see in [this examples folder](https://github.com/19majkel94/type-graphql/tree/master/examples/generic-types).
3 changes: 2 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ All examples has a `examples.gql` file with sample queries/mutations/subscriptio

## Advanced
- [Enums and unions](./enums-and-unions)
- [Interfaces and types inheritance](./interfaces-inheritance)
- [Subscriptions (simple)](./simple-subscriptions)
- [Subscriptions (using Redis)](./redis-subscriptions)
- [Interfaces and types inheritance](./interfaces-inheritance)
- [Resolvers inheritance](./resolvers-inheritance)
- [Generic types](./generic-types)

## Features usage
- [Dependency injection (IoC container)](./using-container)
Expand Down
16 changes: 16 additions & 0 deletions examples/generic-types/examples.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
query GetRecipes {
recipes(first: 3) {
items {
title
ratings
}
total
hasMore
}
}

mutation AddRecipe {
addSampleRecipe {
title
}
}
23 changes: 23 additions & 0 deletions examples/generic-types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import "reflect-metadata";
import { ApolloServer } from "apollo-server";
import * as path from "path";
import { buildSchema } from "../../src";

import RecipeResolver from "./recipe.resolver";

async function bootstrap() {
const schema = await buildSchema({
resolvers: [RecipeResolver],
emitSchemaFile: path.resolve(__dirname, "schema.gql"),
});

const server = new ApolloServer({
schema,
playground: true,
});

const { url } = await server.listen(4000);
console.log(`Server is running, GraphQL Playground available at ${url}`);
}

bootstrap().catch(console.error);
17 changes: 17 additions & 0 deletions examples/generic-types/paginated-response.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ClassType, Field, ObjectType, Int } from "../../src";

export default function PaginatedResponse<TItem>(TItemClass: ClassType<TItem>) {
// `isAbstract` decorator option is mandatory to prevent registering in schema
@ObjectType({ isAbstract: true })
abstract class PaginatedResponseClass {
@Field(type => [TItemClass])
items: TItem[];

@Field(type => Int)
total: number;

@Field()
hasMore: boolean;
}
return PaginatedResponseClass;
}
45 changes: 45 additions & 0 deletions examples/generic-types/recipe.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ObjectType, Query, Mutation, Arg, Int, Resolver } from "../../src";

import PaginatedResponse from "./paginated-response.type";
import Recipe from "./recipe.type";
import createSampleRecipes from "./recipe.samples";

// we need to create a temporary class for the abstract, generic class "instance"
@ObjectType()
class RecipesResponse extends PaginatedResponse(Recipe) {
// simple helper for creating new instances easily
constructor(recipesResponse: RecipesResponse) {
super();
Object.assign(this, recipesResponse);
}

// you can add more fields here if you need
}

@Resolver()
export default class RecipeResolver {
private readonly recipes = createSampleRecipes();

@Query({ name: "recipes" })
getRecipes(
@Arg("first", type => Int, { nullable: true, defaultValue: 10 }) first: number,
): RecipesResponse {
const total = this.recipes.length;
return new RecipesResponse({
items: this.recipes.slice(0, first),
hasMore: total > first,
total,
});
}

@Mutation()
addSampleRecipe(): Recipe {
const recipe: Recipe = {
title: "Sample recipe",
description: "Sample description",
ratings: [1, 2, 3, 4],
};
this.recipes.push(recipe);
return recipe;
}
}
21 changes: 21 additions & 0 deletions examples/generic-types/recipe.samples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Recipe from "./recipe.type";

export default function createSampleRecipes(): Recipe[] {
return [
{
description: "Desc 1",
title: "Recipe 1",
ratings: [0, 3, 1],
},
{
description: "Desc 2",
title: "Recipe 2",
ratings: [4, 2, 3, 1],
},
{
description: "Desc 3",
title: "Recipe 3",
ratings: [5, 4],
},
];
}
13 changes: 13 additions & 0 deletions examples/generic-types/recipe.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Field, ObjectType, Int } from "../../src";

@ObjectType()
export default class Recipe {
@Field()
title: string;

@Field()
description?: string;

@Field(type => [Int])
ratings: number[];
}
24 changes: 24 additions & 0 deletions examples/generic-types/schema.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# -----------------------------------------------
# !!! THIS FILE WAS GENERATED BY TYPE-GRAPHQL !!!
# !!! DO NOT MODIFY THIS FILE BY YOURSELF !!!
# -----------------------------------------------

type Mutation {
addSampleRecipe: Recipe!
}

type Query {
recipes(first: Int = 10): RecipesResponse!
}

type Recipe {
title: String!
description: String!
ratings: [Int!]!
}

type RecipesResponse {
items: [Recipe!]!
total: Int!
hasMore: Boolean!
}
13 changes: 8 additions & 5 deletions src/decorators/InputType.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { getMetadataStorage } from "../metadata/getMetadataStorage";
import { getNameDecoratorParams } from "../helpers/decorators";
import { DescriptionOptions } from "./types";
import { DescriptionOptions, AbstractClassOptions } from "./types";

export type InputTypeOptions = DescriptionOptions & AbstractClassOptions;

export function InputType(): ClassDecorator;
export function InputType(options: DescriptionOptions): ClassDecorator;
export function InputType(name: string, options?: DescriptionOptions): ClassDecorator;
export function InputType(options: InputTypeOptions): ClassDecorator;
export function InputType(name: string, options?: InputTypeOptions): ClassDecorator;
export function InputType(
nameOrOptions?: string | DescriptionOptions,
maybeOptions?: DescriptionOptions,
nameOrOptions?: string | InputTypeOptions,
maybeOptions?: InputTypeOptions,
): ClassDecorator {
const { name, options } = getNameDecoratorParams(nameOrOptions, maybeOptions);
return target => {
getMetadataStorage().collectInputMetadata({
name: name || target.name,
target,
description: options.description,
isAbstract: options.isAbstract,
});
};
}
13 changes: 8 additions & 5 deletions src/decorators/InterfaceType.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { getMetadataStorage } from "../metadata/getMetadataStorage";
import { getNameDecoratorParams } from "../helpers/decorators";
import { DescriptionOptions } from "./types";
import { DescriptionOptions, AbstractClassOptions } from "./types";

export type InterfaceOptions = DescriptionOptions & AbstractClassOptions;

export function InterfaceType(): ClassDecorator;
export function InterfaceType(options: DescriptionOptions): ClassDecorator;
export function InterfaceType(name: string, options?: DescriptionOptions): ClassDecorator;
export function InterfaceType(options: InterfaceOptions): ClassDecorator;
export function InterfaceType(name: string, options?: InterfaceOptions): ClassDecorator;
export function InterfaceType(
nameOrOptions?: string | DescriptionOptions,
maybeOptions?: DescriptionOptions,
nameOrOptions?: string | InterfaceOptions,
maybeOptions?: InterfaceOptions,
): ClassDecorator {
const { name, options } = getNameDecoratorParams(nameOrOptions, maybeOptions);
return target => {
getMetadataStorage().collectInterfaceMetadata({
name: name || target.name,
target,
description: options.description,
isAbstract: options.isAbstract,
});
};
}
Loading

0 comments on commit 084ca96

Please sign in to comment.