FormidableLabs/nextjs-sanity-fe

PoC: Strongly-typed schema

scottrippey opened this issue · 2 comments

GroqD currently supports strongly-typed output types.
However, it is completely unaware of the input types as defined in the Sanity Schema.

Here are some tasks for creating a POC for strongly-typed input types:

  • Extract the Sanity Schema type from the Sanity config, using @sanity-typed/types
  • Create a q that's bound to this schema

API methods to make strongly-typed:

  • q
  • filter
  • filterByType
  • grab
  • grab$
  • grabOne
  • grabOne$
  • select
  • slice
  • order
  • deref
  • score
  • nullable

Key Findings from PoC

A PoC was created in this branch: sanity-typed
This implemented several of the API methods above, making them strongly typed according to the Sanity Schema. Here are my conclusions about this approach.

API changes to support strong types

I was able to leave the GroqD API intact, with only 2 notable changes

  • Instead of using the global q (with no type info), you'd create a "typed q" via export const q = getTypedQ<SanitySchema>(). This simply returned the global q object, but with strong schema type info.
  • A minuscule change to the grab(object) API, where it is now grab(q => object). The q here is again the global q object, but bound with better type info.

Effectiveness of this API

For the most part, strongly-typed schema gives a GREAT developer experience!

  • 🟢 Enables auto-complete for the filterByType('type'), grab({ ... }), and q('field') methods, which are the most used API methods in this app.
  • 🟢 Commands can still be "composed" and reused, with strong types
  • 🤔 There are still a lot of "string-based" commands, like .filter(string) or .sort(string) or conditional grab, which are hard to strongly-type.
    • 🤔 It's possible to use TypeScript to parse these strings, but it's hard (eg. @sanity-typed/groq does this for entire queries)
    • ⚡️ We might be able to use TypeScript template literals to enable some auto-completion type checks, supporting a few common scenarios, but ultimately allowing string as a fallback.

Performance

This might be a prohibiting factor.

  • ⚠️ The InferSchemaValues from @sanity-typed/types is painfully slow.
    • It adds 22s from the CLI, when not cached
    • When tsconfig.tsbuildinfo caching is used, rebuilds are fast
    • This Schema is relatively simple, with only 9 basic document types (eg. Category, Product, Variant, etc). If reduced to just 4 basic docs, the build added 18s. Therefore, I presume the build time grows somewhat linearly, not exponentially.
  • ⛔️ In the IDE, performance is even worse
    • I only tested WebStorm
    • When USING the types (eg. writing queries), changes to code can take 20s+ to refresh type checks in the file
    • Excessively often, the TS engine simply stalled. No indication that it stalled, besides very outdated type errors. Restarting the typescript service didn't always fix it, either. This was excessively painful.

Workarounds

The shape of the inferred schema is pretty basic:

Simplified Example Schema
export type SanitySchemaCompiled = {
  category: {
    slug: { _type: "slug"; current: string };
    name: string;
    description: string;
  };
  product: {
    slug: { _type: "slug"; current: string };
    name: string;
    description: string;
    categories?: Array<{
      _type: "reference";
      _ref: string;
      [_referenced]: "category";
    }>;
  };
};
  • ✅ As a workaround, I hard-coded this schema (like the example above) instead of using InferSchemaValues. All the performance problems were alleviated.
  • ⛔️ This mostly defeats the purpose. The schema should be the source of truth. We don't want to have to manually create types to match the schema.
  • 🟢 However, it does at least provide developers SOME WAY to improve their developer experience when writing groqd queries. Developers might be inclined to manually-type schemas, if it gives auto-completion and type-checks to their queries.
  • ❓ I could not find a way to "pre-compile" the InferSchemaValues output into a flattened structure. I tried compiling the file using tsc --declaration but it just outputs the same InferSchemaValues<typeof config> code. I could not find a way to do this anywhere. Some good discussions were found here: microsoft/TypeScript#34556
  • ❓ Perhaps there is a way to "cross-check" the manual types with the inferred types. This could give us the performance benefits of the workaround, but also ensure our types are matching the "source" schema. This could be contained in a unit test file. For example, const typecheckSchema: SanitySchemaInferred = {} as unknown as SanitySchemaCompiled; .
  • ➡️ After some extra research, I was able to massively improve performance with minimal manual typing. This reduced the 22s down to 2s, and is fairly maintainable. See example below:
Massive Performance Improvement by listing documents

Instead of inferring all types from the entire config, we can infer the exact same type by manually listing all document types. This can live adjacent to our config itself, making it easy to maintain if types were added / removed.

// export type SanitySchema = InferSchemaValues<typeof config>;
export type SanitySchema = {
  category: InferRawValue<typeof category>;
  categoryImage: InferRawValue<typeof categoryImage>;
  description: InferRawValue<typeof description>;
  flavour: InferRawValue<typeof flavour>;
  product: InferRawValue<typeof product>;
  productImage: InferRawValue<typeof productImage>;
  siteSettings: InferRawValue<typeof siteSettings>;
  style: InferRawValue<typeof style>;
  variant: InferRawValue<typeof variant>;
};

Follow-up tasks

After working through this PoC, Grant and I had a discussion that led to an interesting conclusion.

  • The main goal of GroqD is to output strong types for queries
    • Strongly-typed schemas makes this goal even easier
  • The secondary goal of GroqD is to catch type mismatches
    • This is currently achieved via Zod, by throwing runtime errors, so the developer can fix type mismatches during development
    • Strongly-typed schemas allow us to catch type mismatches at compile time
    • This provides a better developer experience, and could make runtime validation optional.

So, this conclusion is leading us to a follow-up PoC -- making Zod optional/swappable, and relying on Schema types and static type checks instead of runtime checks! See #202.

Update: this has been implemented as the groq-builder package in groqd