How do I declare the following typescript interfaces/types correctly?

248 views Asked by At

I'm building an Apollo GraphQL server in Typescript and having trouble understanding the correct way to handle things within the type system. Although GraphQL and Apollo are a part of the code, I'm wondering specifically about the TypeScript part. I'm also having trouble understanding the role of interfaces vs types and what the best practice is for each (i.e., when do you use a type vs an interface, how do you handle extending each, etc).

Most of the unclarity I have is in the resolver. I'll leave comments and questions interspersed in the code next to the relevant parts that I'm asking about. Thank you again for any help you can offer:


type BankingAccount = {
  id: string;
  type: string;
  attributes: SpendingAccountAttributes | SavingsAccountAttributes
}

// I've realized this "SpendingAccountAttributes | SavingsAccountAttributes" is the wrong way
// to do what I'm trying to do. I essentially want to the tell the 
// type system that this can be one or the other. As I understand it, the way it is written
// will create a Union, returning only the fields that are shared between both types, in this
// case what is essentially in the `BankingAttributes` type. Is that correct?

interface BankingAttributes = {
  routingNumber: string;
  accountNumber: string;
  balance: number;
  fundsAvailable: number;
}

// Is it better to remove the `SpendingAccountAttributes` and `SavingsAccountAttribute` specific
// types and just leave them as optional types on the `BankingAttributes`. I will
// in time be creating a resolver for the `SpendingAccount` and `SavingAccount` as standalone
// queries so it seems useful to have them. Not sure though


interface SpendingAccountAttributes extends BankingAttributes {
  defaultPaymentCardId: string;
  defaultPaymentCardLastFour: string;
  accountFeatures: Record<string, unknown>;
}

interface SavingsAccountAttributes extends BankingAttributes {
  interestRate: number;
  interestRateYTD: number;
}

// Mixing types and interfaces seems messy. Which one should it be? And if "type", how can I
// extend the "BankingAttributes" to "SpendingAccountAttributes" to tell the type system that
// those should be a part of the SpendingAccount's attributes?


export default {
  Query: {
    bankingAccounts: async(_source: string, _args: [], { dataSources}: Record<string, any>) : Promise<[BankingAccount]> => {
      // The following makes a restful API to an `accounts` api route where we pass in the type as an `includes`, i.e. `api/v2/accounts?types[]=spending&types[]=savings
      const accounts = await.dataSources.api.getAccounts(['spending', 'savings'])

      const response = accounts.data.map((acc: BankingAccount) => {
        const { fundsAvailable, accountFeatures, ...other } = acc.attributes

        return {
          id: acc.id,
          type: acc.type,
          balanceAvailableForWithdrawal: fundsAvailable,
          // accountFeatures fails the compilation with the following error:
          // "accountFeatures does not exist on type 'SpendingAccountAttributes | SavingsAccountAttributes'
          // What's the best way to handle this so that I can pull the accountFeatures
          // for the spending account (which is the only type of account this attribute will be present for)?
          accountFeatures,
          ...other
        }
      })

      return response
    }
  }
}
1

There are 1 answers

1
Linda Paiste On BEST ANSWER

My rule of thumb is to use interfaces where possible. Basically you can use an interface whenever you are dealing with an object that has known keys (the values can be complex types). So you can make BankingAccount an interface.

The way you have set up the spending and savings accounts to both extend a shared interface is great!

When you have a BankingAccount, you know that it has spending or saving attributes, but you don't know which.

One option is to check which type it is using a type guard.

The other option is to define an additional type which has all of the properties of both account types, but optional.

type CombinedAttributes = Partial<SpendingAccountAttributes> & Partial<SavingsAccountAttributes>

What I personally would do is define your BankingAccount such that it must have complete attributes for either spending or saving, but it the other type's attributes are optional. That means you can access those properties without error, but they might be undefined.

interface BankingAccount = {
  id: string;
  type: string;
  attributes: (SpendingAccountAttributes | SavingsAccountAttributes) & CombinedAttributes 
}

And after typing all of this...I realized that BankingAccount has a type. Is that type "spending" or "savings"? In that case, we also want to draw a link between the type and the attributes.

This BankingAccount type definition should allow you to access attributes of either type while also allowing you to narrow an account as either savings or spending just by checking the value of type. Because of the union here this has to be a type rather than an interface, but that's not really consequential.

type BankingAccount = {
    id: string;
    attributes: CombinedAttributes;
} & ({
    type: "savings";
    attributes: SavingsAccountAtrributes;
} | {
    type: "spending";
    attributes: SpendingAccountAttributes;
}) 

function myFunc( account: BankingAccount ) {

    // interestRate might be undefined because we haven't narrowed the type
    const interestRate: number | undefined = account.attributes.interestRate;

    if ( account.type === "savings" ) {
        // interestRate is known to be a number on a savings account
        const rate: number = account.attributes.interestRate
    }
}

Typescript Playground Link