This post covers an ESLint rule I created to ensure Linear's GraphQL schema types were correctly defined, catching over 250 inconsistencies and preventing runtime errors.
Earlier this year, I landed a change that added some additional logic to a GraphQL mutation. The code looked something like this:
@InputType() class Input { @Field({ nullable: true }) foo: string; } @Query(() => String) async query(@Arg('input') input: Input) { // Use input.foo as if it's always defined }
While foo
is defined as nullable in the GraphQL schema, the corresponding TypeScript type doesn't reflect this.
As a result, our code downstream assumed that foo
would always be defined, leading to a classic null dereference
when foo
was undefined in incoming requests.
This subtle but critical discrepancy passed through code reviews and hit production, triggering a wave of runtime errors and subsequently, erroring requests.
Before diving into the solution, let's set the stage with some context.
At Linear, we leverage a GraphQL API to deliver data to our mobile, desktop, and web clients, as well as to external API consumers.
To generate our GraphQL schema, we utilize type-graphql
, to define the corresponding TypeScript types consumed and provided by our backend services.
Additionally, we use typeorm
to define our database entities and the corresponding GraphQL object types (via type-graphql
).
This incident highlighted the critical need for more rigorous validation between our GraphQL schema and TypeScript types. The issue was clearly addressable and offered the potential for substantial improvements by mitigating future runtime errors. To tackle this, I developed an ESLint rule designed to enforce strict type consistency across our GraphQL schema, TypeScript types, and database entity definitions.
You can find the implementation of the rule here. Now, let's delve into the key components of the rule.
A TypeORM entity class represents a database table and is also used to define the corresponding GraphQL object type. Here’s a simple example:
@Entity() @ObjectType() class User { @Field({ description: "The user's name" }) @Column() name: string; @Field({ description: "The user's bio", nullable: true }) @Column({ nullable: true }) bio?: string; }
We begin by validating our entity classes, ensuring that decorators like @Column
(database layer) and @Field
(GraphQL layer) are in sync with the TypeScript class property.
Here’s a simplified version of the validation logic:
export function validateEntity(node: TSESTree.ClassDeclaration, context: RuleContext): InvalidReport[] { const sourceCode = context.sourceCode; const invalidReports: InvalidReport[] = []; for (const element of node.body.body) { if (element.type !== "PropertyDefinition") { continue; } // ... (code to analyze decorators and property state) const mismatches = determineMismatches(propertyState, decoratorStates); invalidReports.push( ...mismatches.map(reason => ({ reason, element, data: { name: (element.key as TSESTree.Identifier).name }, })) ); } return invalidReports; }
This function iterates through each property in an entity class, analyzing its decorators and type information.
It then determines if there are any mismatches between the TypeScript type and the database schema definition with the determineMismatches
function:
function determineMismatches( propertyState: { isOptional: boolean; hasDefaultValue: boolean; isNullable: boolean }, decoratorStates: { field: { isNullable: boolean; hasDefaultValue: boolean } | null; column: { isNullable: boolean; hasDefaultValue: boolean } | null; relation: { isNullable: boolean } | null; } ): InvalidReason[] { const mismatches: InvalidReason[] = []; // Check for mismatches between @Column and @Field decorators if (decoratorStates.column && decoratorStates.field) { if (decoratorStates.column.hasDefaultValue && decoratorStates.field.isNullable) { // Allowed case } else if (decoratorStates.column.isNullable !== decoratorStates.field.isNullable) { mismatches.push(InvalidReason.ColumnField); } } // ... (additional checks for relations, columns, and fields) return mismatches; }
Here, we’re comparing nullability and default values between the GraphQL @Field
and the database @Column
.
This ensures that nullable fields in one layer don’t incorrectly assume non-nullability in another — a critical check that’s easy to miss during development.
To make this more robust, I introduced additional checks for default values and optional parameters, which can also introduce subtle bugs. For example, a field marked as nullable might incorrectly assume a default value downstream, creating a hidden edge case.
Next, we validate the GraphQL resolver classes. This ensures that the arguments passed to GraphQL queries and mutations are consistent with their TypeScript types.
Here’s how we validate the arguments:
export function validateGQLResolver(node: TSESTree.ClassDeclaration, context: RuleContext): InvalidReport[] { const invalidReports: InvalidReport[] = []; for (const element of node.body.body) { if (element.type === "MethodDefinition" && element.kind === "method") { invalidReports.push(...validateArgs(element, context)); const fieldResolverReport = validateFieldResolver(element, context); if (fieldResolverReport) { invalidReports.push(fieldResolverReport); } } } return invalidReports; }
This function iterates through each method in a resolver class, validating both the arguments and the field resolver decorators.
The validateArgs
function checks the consistency between @Arg
decorators and the corresponding parameter types:
function validateArgs(element: TSESTree.MethodDefinition, context: RuleContext): InvalidReport[] { const invalidArgs: InvalidReport[] = []; for (const param of element.value.params) { if (param.type !== "Identifier" || !param.decorators) { continue; } const argDecorator = param.decorators.find(d => Decorator.matches(d, id => id.name === "Arg")); if (!argDecorator) { continue; } const isArgNullable = Decorator.isNullableTrue(argDecorator, context.sourceCode); const hasDefaultValue = Decorator.hasDefaultValue(argDecorator); const canReceiveNull = isArgNullable && !hasDefaultValue; if (canReceiveNull !== param.optional) { invalidArgs.push({ reason: InvalidReason.ArgProperty, element: param, data: { name: param.name }, }); } } return invalidArgs; }
This function ensures that the nullability of GraphQL arguments matches their TypeScript definitions.
Given the complexity of this rule, I adopted a test-driven development approach, writing extensive unit tests to ensure its correctness. Notably, this was the first custom ESLint rule in our codebase to include tests!
To accommodate the breaking changes in a significant portion of the affected GraphQL types, we introduced a brief deprecation period, allowing teams time to update their code.
Additionally, I implemented a Datadog monitor to alert us to any significant spikes in GraphQL input validation errors post-deployment (which is also useful to catch general breaking changes).
By implementing this ESLint rule, we caught over 250 type inconsistencies in our codebase. These ranged from mismatches between database schemas and TypeScript types to inconsistencies in GraphQL resolver definitions.
For context, Linear's API has over 5000 fields and 700+ types, so while this addressed a small portion, it significantly improved type consistency.
The ESLint rule is now an essential part of our development process, running in CI and catching potential issues long before they reach production. This proactive approach ensures our GraphQL schema, TypeScript types, and database models remain tightly aligned, providing robust type safety throughout our codebase.
Since its implementation, we've seen 0 runtime errors related to type inconsistencies, a testament to the effectiveness of this rule in maintaining code quality and reliability.
If you skipped to the end, here's the link you were looking for: gist containing the ESLint rule implementation.