Making Apollo codegen interfaces type-safe

Marek Scholle
5 min readJul 26, 2020

--

This article shares IMO interesting real-world usage of TS capabilities of making code type safe. The trick with

type ID<T extends string> = string & { 
// fake field (not existing in runtime), ensures that
// `ID<a>` is assignable to `ID<b>` if and only if `a === b`
__anyval__: T
}

is known (although not much used AFAIK), but the way how I make TS compiler propagate this fake types to “marked” interfaces (in this case, interfaces generated by Apollo codegen with their __typename) is innovative. (If you have seen this before, please let me know!)

This is not a theoretic-only stuff, I applied the idea to quite large project (our esports iframe https://www.rtsmunity.com/betting-iframe), gaining more typesafety and code self-documenting. If you find this interesting, please 👏 and share.

Please follow this TS playground.

Say you have a (very simple) GraphQL interface like

type Book {
id: ID!
title: String!
series: Series!
}
type Series {
id: ID!
books: [Book!]
}

and you use this query in your Typescript code:

query MyBook($id: ID!) {
book(id: $id) {
id
series {
id
books {
id
}
}
}
}

If you use Apollo codegen tool, it will generate for you TS interfaces like

interface MyBook {
book: Book_book
}
interface MyBook_book {
__typename: "Book"
id: string
series: Book_book_series
}
interface MyBook_book_series {
__typename: "Series"
id: string
books: Book_book_series_books[]
}
interface MyBook_book_series_books {
__typename: "Book"
id: string
}

That’s perfectly fine, this is a precise representation of JSON payloads you receive from the server for the query. A very nice thing is that there are also generated __typename fields, allowing us to do compile time computation of narrowed (intersection) types for leaf values (strings, numbers).

The problem with generated interface is that ids of Books and Series are both strings, so the code like this compiles:

function f1(bookId: string, seriesId: string) { ... }const b: MyBook_book = ...f1(b.series.id, b.id) // ouch, book.id should go first

What we want is to have “semantic” values like different IDs of different types. To simplify the article, let’s focus on IDs only.

The solution is quite simple: let’s do some type kung-fu. Introduce helper types

type ID<T extends string> = string & {
__anyval__: T
}
type BookId = ID<'Book'>;
type SeriesId = ID<'Series'>;

Please note that ID<T> is an artificial type, there is actually no value in runtime being both string and { __anyval__: T}. The __anyval__ part is just a compile time “tag” attached to BookId and SeriesId so that they are different types, not assignable to each other: the following does not compile:

function f2(bookId: BookId, seriesId: SeriesId) { ... }const bookId: BookId = ...
const seriesId: SeriesId = ...
f2(seriesId, bookId) // compile error

The idea of having different types for different values is well-known, e.g. in Java code you sometimes see primitive values wrapped into dedicated wrapper classes to provide them “meaning”. A neat thing is that in TS this has no runtime penalty, our BookId is a string in the runtime, making it a zero cost abstraction (if you’re coming from C++ or Rust, you surely know this concept well). Also note this plays well with code requiring strings: ID<T> is a string, so it is assignable as argument to any string parameter.

OK, we have defined types BookId and SeriesId, but still we have strings in our codegen output (see MyBook_book). To derive better types for codegen interfaces (MyBook_book), let’s write small metaprogram (type functions):

type Typenames =
| 'Book'
| 'Series';
// given object type `T` (`MyBook_book`)
// and typename literal (`"Book"`),
// it gives appropriate type for `T` (`Book<MyBook_book>`)
type TypenameToType<
N extends Typenames,
T extends Typenamed<N>
> =
N extends 'Book'
? Book<T & Typenamed<N>> // see below
: N extends 'Series'
? Series<T & Typenamed<N>> // see below
: never;
// infers `N` for `TypenameToType` type function from `T`,
// (assumes nonnullable `T`)
type DeriveObjectType<T> =
T extends { __typename: infer N } ?
N extends Typenames
? TypenameToType<N, T>
: never
: never;
// handles possible array-ness of field in GraphQL
// for object type `T`, this is just `DeriveObjectType<T>`,
// for array type, this derives the element type of array
type DeriveTypeNonNullable<T> =
T extends (infer E)[]
? DeriveObjectType<E>[]
: DeriveObjectType<T>;
// handles possible nullability of GraphQL fields
type DeriveType<T> =
T extends null
? DeriveTypeNonNullable<NonNullable<T>> | null
: DeriveTypeNonNullable<NonNullable<T>>;
// given object type `T` (`MyBook_book`)
// and field name (`series`),
// this gives a proper narrowed type for the field
// (`{ series: Series<T['series']> }`)
//
// Note that if a field `F` (`series`) is missing in `T`
// (as known in compile time), this gives empty interface
type DeriveField<T, F extends string> =
F extends keyof T
? { [key in F]: DeriveType<T[F]> }
: {};
// the function for actual leaf values like strings
type Field<T, F extends string, U> =
F extends keyof T
? { [key in F]: U }
: {};
type Book<T extends Typenamed<'Book'>> =
Field<T, 'id', BookId> &
DeriveField<T, 'series'> &
Field<T, 'title', string> &
T;
type Series<T extends Typenamed<'Series'>> =
Field<T, 'id', SeriesId> &
DeriveField<T, 'books'> &
T;

For example, the type Book<MyBook_book> is

{
id: BookId
series: Series<MyBook_book_series>[]
} & Book_book

where Series<Book_book_series> is

{
id: SeriesId;
book: Book<MyBook_book_series_books>[];
} & Book_book_series

Please note that with our Book<T> and Series<T> we carefully avoid “inventing” additional fields not present in T generated by Apollo codegen, e.g. the field series is inBook<T> only if it is in T. Also note that Book<T> can’t be created for a type not having __typename: "Book", protecting us from substitution of anything else then some book.

The last thing we have to do is to actually cast the instance of type T (MyBook_book) to narrowed type (Book<MyBook_book>) — without a need to write the generated type names to ourselves. With all the type functions prepared, the solution is now easy:

// handles null | undefined properly
function gqlCast<
N extends Typenames,
T extends Typenamed<N> | Typenamed<N>[] | null | undefined
>(
obj: T,
):
T extends Typenamed<N>
? TypenameToType<N, NonNullable<T>>
: T extends Typenamed<N>[]
? TypenameToType<N, T[0]>[]
: T extends null
? null
: undefined => {
return obj as any;
}

applied as

const b: MyBook_book = ... // e.g. useQuery<MyBook>(...).data?.book
const book = gqlCast(b)
// book is now properly typed:
f2(book.id, book.series.id) // OK
f2(series.id, book.id) // compile error

Nice, isn’t it? In practice, in a larger project, we about 100 lines of type functions + 100 lines of “specs” like

type Book<T extends Typenamed<'Book'>> =
Field<T, 'id', BookId> &
DeriveField<T, 'series'> &
T;

so the trade-off between additional complexity and gained safery is good.

Note that the example with function f and its parameters may be a bit artificial, but for example a type Map<BookId, Book> you build from data returned from server and casted with gqlCast is much more type-safe, self-documenting than Map<string, Book>.

--

--

No responses yet