@teshies/shape
v0.0.13
Published
A simple type-safe GraphQL schema builder
Readme
@teshies/shape
A type-safe GraphQL schema builder for TypeScript with first-class Grafast integration.
npm install @teshies/shapePeer dependencies: graphql, grafast
Quick start
import n, { makeSchema } from "@teshies/shape";
const User = n.objectType({
name: "User",
definition(t) {
t.field("id", {
type: n.nonNull(n.id),
plan: ($source) => $source.get("id"),
});
t.field("name", {
type: n.string,
plan: ($source) => $source.get("name"),
});
},
});
const Query = n.objectType({
name: "Query",
definition(t) {
t.field("user", {
type: User,
args: {
id: { type: n.nonNull(n.id) },
},
plan: (_, { $id }) => loadOne($id, batchGetUserById),
});
},
});
const schema = makeSchema(n, {
types: [User, Query],
});makeSchema takes a builder instance and a config with your types. It returns a standard GraphQLSchema from graphql-js.
types accepts a flat array of shapes/extensions, a dictionary record, or a namespace import (import * as types):
import * as types from "./schema";
const schema = makeSchema(n, {
types, // Accepts a record dictionary directly
});Builder
The default export n is a builder with methods for creating all GraphQL type shapes and extensions:
| Method | Creates |
|---|---|
| n.objectType(config) | Object type |
| n.interfaceType(config) | Interface type |
| n.inputObjectType(config) | Input object type |
| n.unionType(config) | Union type |
| n.enumType(config) | Enum type |
| n.scalarType(config) | Custom scalar type |
| n.nonNull(type) | Non-null wrapper |
| n.list(type) | List wrapper |
| n.extendType(type, definition) | Type extension |
| n.extendQuery(definition) | Query type extension |
| n.extendMutation(definition) | Mutation type extension |
Built-in scalars are available as n.string, n.int, n.float, n.boolean, n.id. When custom scalars are passed to makeSchema, they are also dynamically registered on the builder instance as camelCase properties (e.g., n.dateTime).
Independent builder instances
While the default export n is a shared builder, you can create isolated builder instances with createBuilder(). This is useful for multi-tenant, microservice, or test environments:
import { createBuilder, makeSchema } from "@teshies/shape";
const builder = createBuilder();
const schema = makeSchema(builder, {
types: [/* ... */],
});Defining types
Object types and interfaces
Object types and interfaces share the same definition API. The definition callback receives a builder t with t.field(), t.connection(), and t.implements():
const Node = n.interfaceType({
name: "Node",
definition(t) {
t.field("id", { type: n.nonNull(n.id) });
},
});
const User = n.objectType({
name: "User",
planType: ($specifier) => userResource.get($specifier),
definition(t) {
t.implements(Node);
t.field("id", {
type: n.nonNull(n.id),
plan: ($source) => $source.get("id"),
});
t.field("name", {
type: n.string,
plan: ($source) => $source.get("name"),
});
},
});Input objects
Define input types for mutations or field arguments. You can also pass isOneOf: true to create a @oneOf input object type:
const CreateUserInput = n.inputObjectType({
name: "CreateUserInput",
definition(t) {
t.field("name", { type: n.nonNull(n.string) });
t.field("email", { type: n.string });
},
});
// Input object with GraphQL @oneOf directive
const SearchByInput = n.inputObjectType({
name: "SearchByInput",
isOneOf: true,
definition(t) {
t.field("userId", { type: n.id });
t.field("email", { type: n.string });
},
});Enums
Members can be an array of strings or a record with values:
const Role = n.enumType({
name: "Role",
members: ["ADMIN", "USER", "GUEST"],
});
// Or with explicit values:
const Status = n.enumType({
name: "Status",
members: {
ACTIVE: { value: "active" },
INACTIVE: { value: "inactive" },
},
});Unions
const SearchResult = n.unionType({
name: "SearchResult",
definition(t) {
t.members(User, Post);
},
});Scalars
Wrap an existing GraphQLScalarType with source, or define one from scratch:
import { GraphQLDateTimeISO } from "graphql-scalars";
// Wrap an existing scalar — uses it directly
const DateTime = n.scalarType({
name: "DateTimeISO",
source: GraphQLDateTimeISO,
});
// Different name — clones the scalar with the new name
const Date = n.scalarType({
name: "Date",
source: GraphQLDateTimeISO,
});
// Custom scalar from scratch
const Json = n.scalarType({
name: "Json",
serialize: (value) => JSON.stringify(value),
parseValue: (value) => JSON.parse(value as string),
});Schema composition
Extending types
Add fields to existing types without modifying their original definition. This is useful for splitting a schema across multiple files:
// Extend the Query type with a connection field
const QueryUsers = n.extendQuery((t) => [
t.connection("allUsers", {
nodeType: User,
nonNull: true,
plan: () => loadMany(constant(null), fetchAllUsers),
}),
]);
// Extend the Mutation type
const MutationUsers = n.extendMutation((t) => [
t.field("createUser", {
type: n.nonNull(User),
args: {
name: { type: n.nonNull(n.string) },
},
}),
]);
// Extend any named type
const UserExtras = n.extendType(User, (t) => [
t.field("posts", { type: n.list(Post) }),
]);
// Pass extensions alongside types in makeSchema
makeSchema(n, {
types: [User, Post, Query, QueryUsers, MutationUsers, UserExtras],
});extendQuery and extendMutation automatically register Query or Mutation root types if they are not explicitly provided in types. The definition callback returns an array of field markers for type inference. Extensions are applied during schema construction.
Relay connections
t.connection() generates *Connection and *Edge types automatically:
const Query = n.objectType({
name: "Query",
definition(t) {
t.connection("allUsers", {
nodeType: User,
enableTotalCount: true,
enablePageInfo: true,
nonNull: true,
});
},
});This generates QueryAllUsersConnection (with nodes, edges, totalCount, pageInfo) and QueryAllUsersEdge (with node, cursor).
Options:
| Option | Default | Description |
|---|---|---|
| nodeType | required | The type of nodes in the connection |
| args | — | Additional custom arguments for the connection field |
| enableTotalCount | false | Add a totalCount field |
| enablePageInfo | false | Add a non-null pageInfo: PageInfo! field (requires a PageInfo type in your schema) |
| nonNull | false | Make all connection, list (nodes, edges), and item (node) fields non-null |
| nonNullConn | false | Make the connection field itself non-null (users: QueryUsersConnection!) |
| nonNullList | false | Make nodes and edges lists non-null ([User]!, [QueryUsersEdge]!) |
| nonNullItem | false | Make individual items in lists non-null ([User!], node: User!) |
| extendConnection | — | Add custom fields to the connection type |
| extendEdge | — | Add custom fields to the edge type |
| grafast | — | Grafast step/plan configuration for connection and edge types ({ connection, edge }) |
Note: Edge cursor: String! is always non-null regardless of nonNull settings.
Customizing Connection and Edge fields
Use extendConnection and extendEdge to add custom fields to generated Connection and Edge types:
const Query = n.objectType({
name: "Query",
definition(t) {
t.connection("allUsers", {
nodeType: User,
extendConnection(t) {
t.field("totalActive", { type: n.int });
},
extendEdge(t) {
t.field("score", { type: n.float });
},
});
},
});Grafast & Codegen
Grafast integration
Object and interface fields accept plan and subscribePlan functions inline, as shown in the examples above. For other Grafast extensions (type-level planType, assertStep, input field applyPlan, connection plans, etc.), see the Grafast documentation.
Schema Codegen
makeSchema can generate TypeScript types for your schema. Pass a codegen option:
const schema = makeSchema(n, {
types,
codegen: {
output: "./src/.generated.ts",
schemaTypesPath: "./schema",
},
});Codegen runs only when NODE_ENV=development. It generates resolver types, Grafast plan types, and DeclaredTypesStrict / DeclaredTypes utility types.
Note on
schemaTypesPath:schemaTypesPath(e.g."./schema") is resolved relative to the output file directory (output: "./src/.generated.ts"->./src/schema.ts). Type alias exports captured fromschemaTypesPathmust be named starting with$followed by an uppercase letter (e.g.$User,$QueryUsers).
Type-safe plan resolvers with DeclaredTypesStrict
The generated file exports DeclaredTypesStrict, a utility type that maps each shape to the types it owns. You use it to declare the Grafast step types for your shapes:
import type { DeclaredTypesStrict } from "./.generated";
import type { LoadedRecordStep, Maybe, Step } from "grafast";
type UserItem = { id: string; name?: string };
export type $User = DeclaredTypesStrict<{
$: typeof User;
User: {
source: LoadedRecordStep<UserItem>;
nullable: Step<Maybe<UserItem>>;
};
}>;$ is the shape instance that owns the declaration. The remaining keys are the GraphQL type names that belong to that shape. Each value defines the Grafast step types (source for the non-null step, nullable for the nullable step).
Once declared, plan functions on that shape's fields become fully typed:
const User = n.objectType({
name: "User",
definition(t) {
t.field("name", {
type: n.string,
// $source is typed as LoadedRecordStep<UserItem>
plan: ($source) => $source.get("name"),
});
},
});For extensions, $ takes the extension instance and its key (a union of TypeName.fieldName) maps to the owned types:
export type $QueryUsers = DeclaredTypesStrict<{
$: typeof QueryUsers;
QueryAllUsersConnection: {};
QueryAllUsersEdge: {};
}>;DeclaredTypesStrict validates at compile time that you declare exactly the types the shape owns, catching mismatches early.
Standalone GraphQL Codegen integration
If you run @graphql-codegen/cli independently outside of makeSchema, @teshies/shape exposes its internal plugins via package entry points:
// codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "./schema.graphql",
generates: {
"./src/.generated.ts": {
plugins: [
"typescript",
"@teshies/shape/codegen-grafast",
{
"@teshies/shape/codegen-shape": {
schemaTypesPath: "./schema",
},
},
],
},
},
};
export default config;How it works
Schema construction happens in two phases:
Registration & Extension — All types are registered in an internal type map. Each type's
definition()runs, and extensions are applied. Type references can be strings (forward references) or shape instances.Resolution & GraphQL Instantiation — Once all types are registered,
toGraphQL()is called on each shape. String references resolve to real instances, interface fields are inherited, andgraphql-jstypes are instantiated.
Each makeSchema() call creates its own isolated type registry, so the same types can be passed to multiple schema builds.
Acknowledgments
The definition API (definition(t) { t.field(...) }, extendType, extendQuery) is inspired by Nexus. Shape drops Nexus's builder shortcuts like t.string("name") and t.nonNull.string(...) in favor of explicit type composition (t.field("name", { type: n.nonNull(n.string) })). Types are values you compose rather than methods on the builder, which keeps the API closer to how graphql-js works and avoids a Nexus-specific vocabulary.
License
MIT
