Making Apollo codegen interfaces type-safe

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
}
type Book {
id: ID!
title: String!
series: Series!
}
type Series {
id: ID!
books: [Book!]
}
query MyBook($id: ID!) {
book(id: $id) {
id
series {
id
books {
id
}
}
}
}
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
}
function f1(bookId: string, seriesId: string) { ... }const b: MyBook_book = ...f1(b.series.id, b.id) // ouch, book.id should go first
type ID<T extends string> = string & {
__anyval__: T
}
type BookId = ID<'Book'>;
type SeriesId = ID<'Series'>;
function f2(bookId: BookId, seriesId: SeriesId) { ... }const bookId: BookId = ...
const seriesId: SeriesId = ...
f2(seriesId, bookId) // compile error
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;
{
id: BookId
series: Series<MyBook_book_series>[]
} & Book_book
{
id: SeriesId;
book: Book<MyBook_book_series_books>[];
} & Book_book_series
// 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;
}
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
type Book<T extends Typenamed<'Book'>> =
Field<T, 'id', BookId> &
DeriveField<T, 'series'> &
T;

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store