DEV Community

Cover image for TypeScript: A Type Narrowing Story
Quan Pham
Quan Pham

Posted on • Updated on

TypeScript: A Type Narrowing Story

There is one day, you have to pull data from 2–3 APIs or even more at the same time just to render them on one little screen. The structure of data each API goes from slightly to completely different. You can’t force your Back-end developers to do a refactor on his code to make those returned data look the same.

What would you do???

My first attempt is to re-map the data from APIs into a common format. Then I realize it is really hard to unify those data. So I come up with this code.

type SomeKindOfInterfaceHere = { hello: string };
type AnotherInterface = { world: boolean };

interface MappedDataFromApi {
  id: string | number;
  data: string[] | SomeKindOfInterfaceHere | AnotherInterface;
}

function AReactComponent(props: MappedDataFromApi) {
  if (props.data.hello) {
    return <>display {props.data.hello} </>
  }

  if (props.data.world) {
    return <>display {props.data.world} </>
  }

  return props.data.map(d => (<>display array item: {d}</>));
}
Enter fullscreen mode Exit fullscreen mode

It works perfectly. Things render fine. But the Typescript starts yelling and prevents me from compiling codes.

Property ‘hello’ does not exist on type >‘SomeKindOfInterfaceHere | AnotherInterface | string[]’.
Property ‘hello’ does not exist on type ‘AnotherInterface’.(2339)

To satisfy Typescript, I refactor my codes into this

interface MappedDataFromApi {
  id: string | number;
  contentVR?: SomeKindOfInterfaceHere;
  documentsInfo?: string[];
  bundleInfo?: AnotherInterface;
}

function AReactComponent(props: MappedDataFromApi) {
  if (props.contentVR) {
    return <>display {props.contentVR.hello} </>
  }

  if (props.bundleInfo) {
    return <>display {props.bundleInfo.world} </>
  }

  return props.documentsInfo && props.documentsInfo.map(d => (<>display array item: {d}</>));
}
Enter fullscreen mode Exit fullscreen mode

Of course, Typescript now can feel better. We’ve created another problem - some might say:

Hey mate, this interface is bad. It has so many ? It is arbitrary. How can I re-use it elsewhere? How can I maintain your code once you not here? Those properties look mysterious.

Sounds cruel but reasonable!

OK, let try once again. I separate my interface into smaller pieces. It looks neat, no more ?, but…

interface VerificationRequest {
  uuid: string;
  content: SomeKindOfInterfaceHere;
}

interface SingleVerification {
  id: number;
  documents: string[];
}

interface Bundle {
  id: number;
  info: AnotherInterface;
}

type MappedDataFromApi = VerificationRequest | SingleVerification | Bundle;

function AReactComponent(props: MappedDataFromApi) {
  if (props.content) {
    return <>display {props.content.hello} </>
  }

  if (props.info) {
    return <>display {props.info.world} </>
  }

  return props.documents.map(d => (<>display array item: {d}</>));
}
Enter fullscreen mode Exit fullscreen mode

Brrrrr, Typescript yells at me again with the same problem as before.

Property ‘content’ does not exist on type ‘MappedDataFromApi’.
Property ‘content’ does not exist on type ‘SingleVerification’.(2339)

Luckily, Typescript has these gems to help us write better codes and have good typing in this case.

Using type predicates

With this method, I can add some utility functions to support Typescript detect what kind of interface I’m working on. The codes will look like this.

function isVerificationRequest(props: MappedDataFromApi): props is VerificationRequest {
  return !!(props as VerificationRequest).content;
}

function isSingleVerification(props: MappedDataFromApi): props is SingleVerification {
  return Array.isArray((props as SingleVerification).documents);
}

function isBundle(props: MappedDataFromApi): props is Bundle {
  return !!(props as Bundle).info;
}

function AReactComponent(props: MappedDataFromApi) {
  if (isVerificationRequest(props)) {
    return <>display {props.content.hello} </>
  }

  if (isBundle(props)) {
    return <>display {props.info.world} </>
  }

  return props.documents.map(d => (<>display array item: {d}</>));
}
Enter fullscreen mode Exit fullscreen mode

Beautiful, right? 👏👏👏

One thing is that this style will make my final JS code size a little bigger. You can check the JS compile version on Typescript Playground.

Discriminated unions

With this method, I can add one common property with literal type into interfaces. The codes will look like this.

interface VerificationRequest {
  uuid: string;
  content: SomeKindOfInterfaceHere;
  kind: 'verification-request';
}

interface SingleVerification {
  id: number;
  documents: string[];
  kind: 'single-verification';
}

interface Bundle {
  id: number;
  info: AnotherInterface;
  kind: 'bundle';
}

type MappedDataFromApi = VerificationRequest | SingleVerification | Bundle;

function AReactComponent(props: MappedDataFromApi) {
  switch (props.kind) {
    case 'verification-request':
      return <>display {props.content.hello} </>
    case 'single-verification': 
      return props.documents.map(d => (<>display array item: {d}</>));
    case 'bundle':
      return <>display {props.info.world} </>
    default:
      return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

It looks neat too. You can even make Exhaustiveness checking with this style. But on the other hand, if you want to re-use the interfaces elsewhere, you have to Omit the common property or manually add it to your data collection. If not, Typescript once again yelling at you.

Here are what I’m talking about:

// drop "kind" by create a new Omit type
type NewSingleVerification = Omit<SingleVerification, "kind">
function getSingleVerification(): NewSingleVerification {
  return {
    id: 1,
    documents: ['education', 'license'],
  };
}

// OR
function getSingleVerification(): SingleVerification {
  return {
    id: 1,
    documents: ['education', 'license'],

    // manual add this
    kind: 'single-verification',  
  };
}
Enter fullscreen mode Exit fullscreen mode

This is a huge downside to me since it makes UI issues involve in the business logic where they shouldn’t.

Conclusion

These are all solutions that I can come up with. Each one has its own downside, but at least the last 2 can cover almost my team's concerns about type checking and everyone can comprehend the codes easily.
If you have any other solutions, please comment below.


Thank For Reading

Top comments (0)