Skip to Page NavigationSkip to Page NavigationSkip to Content
Keystone 6 is in Community Preview! What does that mean? see our Roadmap. For Keystone 5 docs, visit v5.keystonejs.com

Virtual Fields

Keystone lets you define your data model in terms of lists, which have fields. Most lists will have some scalar fields, such as text and integer fields, which are stored in your database.

It can also be helpful to have read-only fields which are computed on the fly when you query them. Keystone lets you do this with the virtual field type.

Virtual fields provide a powerful way to extend your GraphQL API. In this guide we'll introduce the syntax for adding virtual fields, and show how to build up from a simple to a complex example.

Hello world

We'll start with a list called Example and create a virtual field called hello.

import { config, createSchema, list } from '@keystone-next/keystone/schema';
import { virtual } from '@keystone-next/fields';
import { schema } from '@keystone-next/types';
export default config({
lists: createSchema({
Example: list({
fields: {
hello: virtual({
field: schema.field({
type: schema.String,
resolve() {
return "Hello, world!";
},
}),
}),
},
}),
}),
});

We can now run a GraphQL query and request the hello field on one of our Example items,

{
Example(where: { id: "1" }) {
id
hello
}
}

which gives the response:

{ Example: { id: "1", hello: "Hello, world! } }

The value of hello is generated from the resolve function, which returns the string "Hello, world!".

The schema API

The virtual field is configured using functions from the schema API in the @keystone-next/types package. This API provides the interface required to create type-safe extensions to the Keystone GraphQL schema. The schema API is based on the @graphql-ts/schema package.

The virtual field accepts a configuration option called field, which is a schema.field() object.

In our example we passed in two required options to schema.field(). The option type: schema.String specifies the GraphQL type of our virtual field, and resolve() { ... } defines the GraphQL resolver to be executed when this field is queried.

The schema API provides support for the built in GraphQL scalar types Int, Float, String, Boolean, and ID, as well as the Keystone custom scalars Upload and JSON.

Resolver arguments

The resolve function accepts arguments which let you write more sophisticated virtual fields. The arguments are (item, args, context, info). The item argument is the internal item representing the list item being queried. Refer to the internal items guide for details on how to work with internal items in Keystone. The args argument represents the arguments passed to the field itself in the query. The context argument is a KeystoneContext object. The info argument holds field-specific information relevant to the current query as well as the schema details.

We can use the item and context arguments to query data in our Keystone system. For example, if have a blog with Author and Post lists, it might be convenient to have an authorName field on the Post list. We can do this with a virtual field which queries for the related author and returns their name.

export default config({
lists: createSchema({
Post: list({
fields: {
content: text(),
author: relationship({ ref: 'Author', many: false }),
authorName: virtual({
type: schema.String,
field: schema.field({
async resolve(item, args, context) {
const { author } = await context.lists.Post.findOne({
where: { id: item.id.toString() },
query: 'author { name }',
});
return author && author.name;
},
}),
}),
},
}),
Author: list({
fields: {
name: text({ isRequired: true }),
},
}),
}),
});

GraphQL arguments

Continuing with our blog example, we may want to extract an excerpt of each blog post for display on the home page. We could query the full Post.content field for each post and then slice it in the client, but it would be nicer if we could ask for just the slice that we want from the GraphQL API.

To do this we can use a virtual field which takes a length argument and then performs the .slice() operation as part of resolver function. This gives control of the size of the excerpt to the frontend while getting the backend to do the actual work. We use the args option to define the GraphQL field arguments we want to support.

export default config({
lists: createSchema({
Post: list({
fields: {
content: text(),
excerpt: virtual({
field: schema.field({
type: schema.String,
args: {
length: schema.arg({
type: schema.nonNull(schema.Int),
defaultValue: 200
}),
},
resolve(item, { length }) {
if (!item.content) {
return null;
}
const content = item.content as string;
if (content.length <= length) {
return content;
} else {
return content.slice(0, length - 3) + '...';
}
},
}),
graphQLReturnFragment: '(length: 500)',
}),
},
}),
}),
});

This will generate the following GraphQL type:

type Post {
id: ID!
content: String
excerpt(length: Int! = 200): String
}

We can now perform the following query to get all the excerpts without over-fetching on the client.

{
allPosts {
id
excerpt(length: 100)
}
}

As well as passing in the field definition, we have also passed in graphQLReturnFragment: '(length: 500)'. This is the value used when displaying the field in the Admin UI, where we want to have a different length the default of 200. Had we not specified defaultValue in our field, the graphQLReturnFragment argument would be required, as the Admin UI would not be able to query this field without it.

GraphQL objects

The examples above returned a scalar String value. Virtual fields can also be configured to return a GraphQL object.

In our blog example we might want to provide some statistics on each blog post, such as the number of words, sentences, and paragraphs in the post. We can set up a GraphQL type called PostCounts to represent this data using the schema.object() function.

export default config({
lists: createSchema({
Post: list({
fields: {
content: text(),
counts: virtual({
field: schema.field({
type: schema.object<{
words: number;
sentences: number;
paragraphs: number;
}>()({
name: 'PostCounts',
fields: {
words: schema.field({ type: schema.Int }),
sentences: schema.field({ type: schema.Int }),
paragraphs: schema.field({ type: schema.Int }),
},
}),
resolve(item: any) {
const content = item.content || '';
return {
words: content.split(' ').length,
sentences: content.split('.').length,
paragraphs: content.split('\n\n').length,
};
},
}),
graphQLReturnFragment: '{ words sentences paragraphs }',
}),
},
}),
}),
});

This example is written in TypeScript, so we need to specify the type of the root value expected by the PostCounts type. This type must correspond to the return type of the resolve function.

Because our virtual field has an object type, we also need to provide a value for the option graphQLReturnFragment. This fragment tells the Keystone Admin UI which values to show in the item page for this field.

Self-referencing objects

This information is specifically for TypeScript users of the schema.object() function with a self-referential GraphQL type.

GraphQL types will often contain references to themselves and to make TypeScript allow that, you need have an explicit type annotation of schema.ObjectType<RootVal> along with making fields a function that returns the object.

type PersonRootVal = { name: string; friends: PersonRootVal[] };
const Person: schema.ObjectType<PersonRootVal> = schema.object<PersonRootVal>()({
name: "Person",
fields: () => ({
name: schema.field({ type: schema.String }),
friends: schema.field({ type: schema.list(Person) }),
}),
});

Keystone types

Rather than returning a custom GraphQL object, we might want to have a virtual field which returns one of the GraphQL types generated by Keystone itself. For example, for each Author we might want to return their latestPost as a Post object.

To achieve this, rather than passing in schema.field({ ... }) as the field option, we pass in a function lists => schema.field({ ... }). The argument lists contains the type information for all of the Keystone lists. In our case, we want the output type of the Post list, so we specify type: lists.Post.types.output.

export const lists = createSchema({
Post: list({
fields: {
title: text(),
content: text(),
publishDate: timestamp(),
author: relationship({ ref: 'Author.posts', many: false }),
},
}),
Author: list({
fields: {
name: text({ isRequired: true }),
email: text({ isRequired: true, isUnique: true }),
posts: relationship({ ref: 'Post.author', many: true }),
latestPost: virtual({
field: lists =>
schema.field({
type: lists.Post.types.output,
async resolve(item, args, context) {
const { posts } = await context.lists.Author.findOne({
where: { id: item.id.toString() },
query: `posts(
orderBy: { publishDate: desc }
first: 1
) { id }`,
});
if (posts.length > 0) {
return context.db.lists.Post.findOne({
where: { id: posts[0].id }
});
}
},
}),
graphQLReturnFragment: '{ title publishDate }',
}),
},
}),
});

Once again we need to specify graphQLReturnFragment on this virtual field to specify which fields of the Post to display in the Admin UI.

Working with virtual fields

Virtual fields provide a powerful way to extend your GraphQL API, however there are some considerations to keep in mind when using them.

The virtual field executes its resolver every time the field is requested. For trivial calculations this isn't a problem, but for more complex calculations this can lead to performance issues. In this case you can consider memoising the value to avoid recalculating it for each query. Another way to address this is to use a scalar field and to populate its value each time the item is updated using a hook.

The other main consideration is that it is not possible to filter on a virtual field, as each item calcutes its value dynamically, rather than having it stored in the database. Using a pre-calculated scalar field is the best solution to use if you need filtering for your field.