Back to Front

Getting data into your app, safely

Nick Fisher / SoundCloud / @spadgos

Quick preamble

Warning

This talk is mostly code

Two minute TypeScript Intro

  • Any familiarity with types will do
  • Literal types fn = (x: 42) => x; fn = (x: 'hi') => x;
  • Generics

Generics


              const arrOfStrings: string[] = [];
              const arrOfStrings: Array<string> = [];
            

              // using generics in a function signature
              function identity<T>(x: T): T {
                return x;
              }
            

              // using generics in an interface
              interface Resource<T> {
                id: T;
              }
            

TypeScript in the front-end


              interface Person {
                firstName: string;
                lastName: string;
              }
            

              function getFullName(person: Person): string {
                return person.firstName + ' ' + person.lastName;
              }
              
              const person: Person = {
                firstName: 'Grace',
                lastName: 'Hopper'
              };
              
              const name = getFullName(person);
            

Let's begin

The steps

  1. Back-end generates a domain model
  2. Back-end serializes for transport (eg: JSON.stringify)
  3. A series of tubes
  4. Front-end deserializes (eg: JSON.parse)
  5. Front-end hydrates to domain model
    • (eg: new User(data))

A Common Approach


            makeRequest(`users/${id}/tracks?limit=${limit}`)
              .then(parseResponse);
          

            function parseResponse(data: any): Array<Track> {
              return data;
            }
          

"Trust me"

Endpoints config


            const endpoints = {
              userTracks: {
                url: '/users/:id/tracks',
                query: {
                  limit: 10
                }
              },
              trackStats: {
                url: '/tracks/:id/stats/:category'
              }
            }
          

Endpoints usage


            Endpoints.callEndpoint(
              'userTracks', { id: 123 }, { limit: 5 }
            ).then( ... );
          
  • Cleaner to use
  • Easier to test
  • Easier to find API usage
  • Single point of config
  • But... no types yet

Let's add some types then


              Endpoints.callEndpoint(
                'userTracks', { id: 123 }, { limit: 5 }
              ).then( ... );
            

              type EndpointsInstance = {
                callEndpoint: (
                  name: 'userTracks',
                  pathParams: { id: number },
                  queryParams: { limit?: number }
                ) => Promise<Array<Track>>
              };
            

↓ Demo

One down...


              type EndpointsInstance = {
                callEndpoint: ((
                  name: 'userTracks',
                  pathParams: { id: number },
                  queryParams: { limit?: number }
                ) => Promise<Array<Track>>)
                & ((
                  name: 'trackStats',
                  pathParams: {
                    id: number,
                    category: 'plays' | 'likes' | 'reposts'
                  },
                  queryParams: {}
                ) => Promise<Stats>)
              };
            

↓ Demo

Problem: Tooling support not great

Solution: Partial application!


            (name: string, pathParams: object, queryParams: object) => Promise;
            (name: string) => (pathParams: object, queryParams: object) => Promise;
          

            callEndpoint('user')({ id: 123 }, {}).then((user) => ...)
          

Problem: Repetitive

Solution: Generics to the rescue!


            type Endpoint<N, P, Q, R>
              = (name: N) => (pathParams: P, queryParams: Q) => Promise<R>
          

            type EndpointsInstance = {
              callEndpoint:
                Endpoint<
                  'userTracks',
                  { id: number }, { limit?: number },
                  Array<Track>
                >
              & Endpoint<
                  'trackStats',
                  {
                    id: number,
                    category: 'plays' | 'likes' | 'reposts'
                  }, {}, Stats
                >
            };
          

Can be modularized


              // search-endpoints.ts
              export Users = Endpoint<'searchUsers', null, { query: string }, User[]>
              export Tracks = Endpoint<'searchTracks', null, { query: string }, Track[]>
            

              // user-endpoints.ts
              export User = Endpoint<'user', { id: number }, {}, User>
            

              import * as Search from './search-endpoints';
              import * as User from './user-endpoints';

              type EndpointsInstance = {
                callEndpoint: Search.Users & Search.Tracks & User.User;
              }
            

↓ Demo

Hang on a second...

How is this any safer than before?

It isn't! (much)

"Trust me"

Verifying your types

One way to verify a type def... use TS itself


          function verifyUser(data: User) {}

          verifyUser({ id: 123, firstName: 'Ada' });
          verifyUser({ id: 11, firstName: 11 }); // error
          

            function verify<R>(data: R): void {}
          

Grab some fixtures, away you go

...but don't forget to maintain them

Verifying your plumbing


          callEndpoint('user')({ id: 123 }, {}); // => Promise<User>
          

Hang on a second...

Don't we have everything we need here?


            verify('user')({ id : 123 }, {})({
              id: 123,
              firstName: 'Ada'
            });
          

            callEndpoint:
              (name: N) => (path: P, query: Q) => Promise<R>
            verify:
              (name: N) => (path: P, query: Q) => (expectedResult: R) => void;
          

Now, just put the right fixture with the right call

Duplicate all the endpoints?


            type CallUser = Endpoint<'user', { id: number }, {}, User>
            type VerifyUser = Verify<'user', { id: number }, {}, User>
          

Nope


            type Endpoint<N, P, Q, R> = (name: N) => (path: P, query: Q) => Promise<R>

            type Endpoint<N, P, Q, R> = {
              call: (name: N) => (path: P, query: Q) => Promise<R>
              verify: (name: N) => (path: P, query: Q) => (result: R) => void;
            }
          

            type EndpointsInstance = {
              callEndpoint: Search.Tracks['call']
                          & Search.Users['call'] & ... // etc
            }

            type Verify = Search.Tracks['verify']
                        & Search.Users['verify'] & ... // etc
          

Getting the fixtures

Remember what we're building here...


                  verify('user')({ id : 123 }, {}); // (User) => void
            callEndpoint('user')({ id : 123 }, {}); // Promise<User>
          

We can test the library using the library itself

Testing process


            verify('user')({ id: 123 }, {});
            verify('user')({ id: 234 }, {});
          
  1. Using AST magic (thanks to tspoon)...
  2. Find the verify calls
  3. Actually call the endpoints library with the args
  4. Rewrite the code to:
    
                    verify('user')({id: 123}, {})({ id: 123, firstName: 'Ada' });
                    verify('user')({id: 234}, {})({ id: 234, firstName: 'Hedy' });
                  
  5. Then just run tsc on the modified code

Benefits

  • Tests the full integration with the API
  • Can be run as part of the API tests to catch regressions
  • Super simple interface for tests

Downsides

  • No coverage
  • Not as expressive as JSON Schema
  • Only covers the happy case

In summary