twitter.com/garybernhardt/status/1104882486809509888
In other words, having a single source of truth for the API description that allows to validate client code against server changes at build time (or the other way around).
Once we have our source of truth, we'd like to exploit it and avoid to write custom validations of API inputs.
Other than validation, how much boilerplate code can we delegate to our solution? E.g. client code generation.
Can we avoid an additional build step?
apollo client:codegen [options]
graphql-codegen
Scala
-> TypeScript
metarpheus > metarpheus-io-ts
github.com/buildo/metarpheus
Same power to express concepts at both ends.
(still, data has to be (en/de)coded to/from the underlying representation, e.g. HTTP/JSON)
You can reuse the same source code.
(some care needed in webpack.config/tsconfig)
Validation, business logic,... Unlike e.g. between Scala
and TS
Repo: github.com/giogonzo/fullstack-ts-http-api
git checkout step-0
no validation of input (any
hides this at a first glance)
app.get('/getPostById', (req, res) => {
// req.query is just `any`: we can
// do what we want with it
const postId = req.query.id
// our postId is `any` in turn,
// meaning it is assignable to anything
postService.getById(postId).then(
post => res.status(200).send(post)
)
})
- Define "codecs" in the io-ts DSL (values)
import * as t from 'io-ts'
const User = t.type({ name: t.string, age: t.number })
- Values are used at runtime for validations
User.decode({ name: 'gio', age: 30 }).fold(
errors => {},
user => {}
)
- Static types can be derived with the type-level operator
TypeOf
type User = t.TypeOf<typeof User>
// same as type User = { name: string, age: number }
-----(client)----- ---------(API)--------------
request: encode --> JSON --> decode --> process request
Note that decode
can fail
t.string.decode(1).fold(
(errors: ValidationErrors) => {},
(s: string) => {}
)
... but encode
never fails: given an A
we always know how to obtain an O
t.string.encode('2') // '2'
git checkout step-1
We obtained a nice property: if the API changes input or output types for the get post API call, TS
will complain in our client build.
Suppose we added an additional publishedOnly: boolean
filter the the API. In our client code:
fetchPostById({ id: 'foo' })
// TS will complain with "missing 'publishedOnly' key"
Moving on:
git checkout step-2
We already had an implicit encode()
for Date
: toString()
,
but we forgot to decode()
client-side.
-----(client)------ ---------(API)--------------
request: encode --> JSON --> decode --> process request
response: decode <-- JSON <-- encode <-- send response
Codecs are more than just “validators”, they can also transform values with encode
/decode
A codec of type Type<A, O>
- represents the static type
A
at runtime - can encode
A
intoO
- can decode
unknown
intoA
, or fail with validation errors
We need something that is a Type<Date, string>
- represents the static type
Date
at runtime - can encode
Date
intostring
- can decode
unknwon
intoDate
, or fail with validation errors
We could write our own codec using new t.Type()
or pick one already defined in io-ts-types
.
git checkout step-3
import {
DateFromISOString
} from 'io-ts-types/lib/Date/DateFromISOString'
export const GetPostByIdOutput = t.type({
title: t.string,
body: t.string,
date: DateFromISOString
})
git checkout step-4
Having added a second API call... our implementations (both API and client) are exactly the same, given:
- an
Input
codec - an
Output
codec - a
path: string
to provide toexpress
Generalising sort of makes sense given the various simplifications in our example, e.g.:
- all of our calls can be
GET
with aquery
Given this representation for our API calls:
interface APICallDefinition<IA, IO, OA, OO> {
path: string
input: Type<IA, IO>
output: Type<OA, OO>
}
We need a way to:
- define API calls
- implement them server-side
- add an implemented API call to express
- derivate the corresponding client call
Let's recap some useful TS features first.
They make the description of the program more precise
const identity1 = (x: number): number => x;
[1, 2].map(identity1);
// Type 'string' is not assignable to type 'number'.
['a', 'b'].map(identity1);
const identity2 = (x: unknown): unknown => x;
// (parameter) x: unknown
['a', 'b'].map(identity2).map(x => {});
const identity = <A>(x: A): A => x;
// (parameter) x: string
['a', 'b'].map(identity).map(x => {});
typescriptlang.org/docs/handbook/generics.html
They allow to add constraints to function signatures
function prop<
O,
K extends keyof O
>(obj: O, prop: K): O[K] {
return obj[prop]
}
prop({ foo: 'foo', bar: 'bar' }, 'baz')
// Argument of type '"baz"' is not assignable to
// parameter of type '"foo" | "bar"'
typescriptlang.org/docs/handbook/generics.html
git checkout step-5
- helper to define an API call
// api/src/lib.ts
function defineAPICall<IA, IO, OA, OO>(
config: APICallDefinition<IA, IO, OA, OO>
): APICallDefinition<IA, IO, OA, OO>
Example usage:
export const getPostById = defineAPICall({
path: '/getPostById',
input: GetPostByIdInput,
output: GetPostByIdOutput
})
- helper to implement an API call server-side
function implementAPICall<IA, IO, OA, OO>(
apiCall: APICallDefinition<IA, IO, OA, OO>,
implementation: (input: IA) => Promise<OA>
): APICall<IA, IO, OA, OO>
Example usage:
const getPostById = implementAPICall(
definitions.getPostById,
input => service.getById(input.id)
)
- helper to add an implemented API call to
express
// api/src/lib.ts
function addToExpress<IA, IO, OA, OO>(
app: Application,
apiCall: APICall<IA, IO, OA, OO>
): void
Example usage:
import * as apiCalls from './implementations'
addToExpress(app, apiCalls.getPostById)
- helper to derive the corresponding client call
// web/src/lib.ts
function makeAPICall<IA, IO, OA, OO>(
apiEndpoint: string,
apiCall: APICallDefinition<IA, IO, OA, OO>
): (input: IA) => Promise<OA>
Example usage:
import * as implementations from '../api/src/implementations'
const getPostById = makeAPICall(
'localhost:3000',
implementations.getPostById
)
getPostById({ id: '2' }).then(post => {})
twitter.com/garybernhardt/status/1107738508288876544
We could add...
- support for more HTTP methods with different semantics
- support other common cases, e.g.
Authorization: Bearer ${token}
- error handling
- remove
path
from DSL
Simple & custom, but it works in production ™️
- Our solution works only if we control the project full-stack
- If there's a need to support more verbs, headers etc. it can get quickly out of hands
- No full-stack TS glue that I know of?
- API-side:
hyper-ts
+io-ts
+fp-ts-router
?
- this repo: github.com/giogonzo/fullstack-ts-http-api
We have ignored the fact that our service implementation can fail.
Let's see how to add simple error handling to getPostById
.
git checkout step-7
API implementation is not coherent with the definition:
export const getPostById = implementAPICall(
definitions.getPostById,
// Type 'Promise<Option<Post>>' is not
// assignable to type 'Promise<Post>'.
input => service.getById(input.id)
)
We have to update the definition accordingly, and serialize the Option
to the client.
This is easy and once again we use an io-ts
combinator from io-ts-types
.
git checkout step-8
The client implementation is not coherent with the definition:
// Type 'Option<Post>' is not
// assignable to type 'Post'.
renderPost(post)
We need the ability to handle the possible API failure.
Let's update our render method using Option.fold
.
Final solution:
git checkout step-9
For each API call definition, we are providing an arbitrary path: string
to match the HTTP
request.
With the current DSL we can define and re-use API calls one by one, but we can't operate on the complete set of calls all at once.
All of this is fine, but one may think at the DSL differently, to operate on records instead:
addAllToExpress(app, implementations)
const API = makeAPI(definitions)
Let's recap some useful TS features first.
type User = {
name: string
age: number
}
const user: User = { name: 'gio', age: 30 }
type UserAge = User['age'] // number
const userAge: UserAge = user['age'] // 30
More than just object string properties:
type Posts = Array<Post>
type PostTitle = Posts[number]['title'] // string
Define types by enumerating on properties
type Record<K extends PropertyKey, A> = { [k in K]: A }
Various use cases, e.g.
type Box<T> = {
[K in keyof T]: { value: T[K] }
}
type Pick<T, K extends keyof T> = { [k in K]: T[k] }
typescriptlang.org/docs/handbook/advanced-types.html
www.typescriptlang.org/docs/handbook/advanced-types.html
T extends U ? X : Y
type APICallInputType<C> =
C extends APICall<infer I, any, any, any>
? I
: never
Final solution:
git checkout step-9