TypeScript: Choose classes over interfaces to decouple your UI data model from the server

In this post I describe how using TypeScript interfaces can leave front-end code tightly-coupled to the server-side data model, and the negative consequences that entails.

I then go on to describe how the use of TypeScript classes can help front-end developers to decouple from the server-side data model, transform data so it is easier to read and use, and make testing easier.

Using interfaces to define server-side data in a front-end application

It’s very common to see front-end TypeScript code that uses interfaces to define the shape of incoming server-side data.

export interface Contact {
    firstName: string;
    lastName: string;
    addresses: Address[];
}

export interface Address {
    street: string;
    city: string;
    postcode: string;
    type: string;
}

// Angular Service
@Injectable()
export class ContactsService {
    constructor(private db: DB) {}

    // This method gets server-side contact data.
    getContact(id: string): Observable<Contact> {
        return this.db.getContact(id);
    }
}

You can see the shape of the data returned by the ContactsService (above) is defined by a couple of interfaces, Contact and Address.

These type-definitions confer several benefits to a developer, including:

  • Auto-completion.
  • Improved code-navigation.
  • Type-checking showing warnings for any spelling mistakes or non-conformance to types (shown below).
// Angular component
@Component({ selector: 'sales-agent' })
export class SalesAgentComponent implements OnInit {

    ngOnInit() {
        this.contactsService.getContact()
            .subscribe((contact: Contact) => {
            // IDE shows error because surname NOT a property
            // of the Contact interface, should be lastName
            const surname = contact.surname;
        });
    }
}

This is all very useful compile-time behaviour, but…

…in production, what data is the server actually sending?

Hopefully you've had those conversations with the server-side developers a long time ago, or they’ve provided you a spec. You’ve also checked out the docs of any third-party apis that the UI is consuming directly.

So you’re confident of the shape of data coming into the front-end.

For now…

My point is that in this setup (using interfaces), the front-end model remains tightly-coupled to the server-side model, which has several negative consequences.

Negative consequences of using interfaces

Firstly, the UI is tightly-coupled to sources of change.

Changes of shape

Perhaps you are calling your company’s internal client data service to get contact data. What happens when the server-side devs decide to change the shape of that data? For whatever reason, contact.lastName will now be changed to contact.surname. Now you will need to find every example of contact.lastName in your codebase and change it to contact.surname.

This trivial example is simple enough to fix with modern IDE’s and type-checking perhaps. The general point is, though, that you need to make sure you find every item which needs changing, which is an opportunity to make a mistake. Then, you need to update it correctly, which is another opportunity to make a mistake.

Ideally, we could isolate the bulk of our front-end code from changes, and reduce the risk of introducing new bugs.

Changes of source data

Although you’ve so far been exclusively getting your contacts data from the company’s internal service, marketing now also wants to get prospects from a 3rd party api. The data from the new source needs to be displayed in the same way on the front-end, so ideally we would still use the same UI components, but the shape of the new prospects data doesn’t match your existing Contact interface.

How can we handle both sources of data on the front-end without needing to define multiple interfaces and introduce complicated code in our presentation components?

Secondly, even when the source doesn’t change, we are tightly-coupled to the shape and quality of the data when using interfaces.

As interfaces don’t offer us the ability to transform the server-side data in any way, we need to deal with several issues concerning the appropriateness of the shape and quality of incoming data.

Defensive programming can clutter up our component code.

Let’s say we want to use array methods on contact.addresses in our component code, but the api sends null instead of an empty array. We could add this possibility to the Contact interface type definition, but we are still forced to use defensive programming in our presentation components to ensure a reference error won’t ever be thrown.

Hopefully we’ll remember to do that every time…

// Server-side Contact data
{
    firstName: 'Terry',
    // Wish we could be sure this was
    // *always* an array.
    addresses: null
}
// Component...
// contact.address could be null.
// Defensive programming required every time
// we want to use it in a component.
if(Array.isArray(contact.addresses)) {
    // Now we know that Array methods are
    // safe to use with contact.addresses.
}

The data might be awkward to use

It’s unlikely that any 3rd party api, or even our own company data service, is written with the requirements our UI in mind. The structure simply might not suit our UI components, or may be deeply nested, making it awkward to use, prone to reference errors, and require lots of ugly defensive programming.

// Api Account data
{
    name: 'Wibble Ltd',
    lots: {
        of: {
            defensive: {
                programming: 'Arrrggghh!'
            }
        }	
    }
}
// Component...
if(account?.lots?.of?.defensive?.programming === 'Arrrggghh!) {
    // Finally safe to use...
}

Classes to the rescue!

Many of these issues can be solved simply by using classes to represent the front-end data model, rather than interfaces.

Decouple from the server-side model by mapping data.

TypeScript classes can act as a mapper that decouples your client-side model from the server, or api, model.

export class Contact {
    firstName: string;
    lastName: string;
    addresses: Address[];
    status: string;

    public static createInstance(raw): Contact {
        raw = raw || {};
        return new Contact(raw);
    }

    public static createList(raw): Contact[] {
        raw = raw || [];
        return raw.map(item => {
            return new Contact(item);
        });
    }

    constructor(raw) {
        // defaults to empty string
        this.firstName = raw.firstName || '';
        // defaults to empty string
        this.lastName = raw.lastName || '';
        // defaults to empty array so we can
        // use Array methods without checking for null
        this.addresses = raw.addresses 
        	? Address.createList(raw.addresses) || [];
        // defaults to 'active'
        this.status = raw.status || 'active';
    }
}

OK. The class is a lot more code than the interface. But taking the raw server-side data and mapping it to instances of the Contact class allows us to transform the data and solve many of the problems caused by tight-coupling:

// Angular Service
@Injectable()
export class ContactsService {
    constructor(private db: DB) {}
    
	getContact(id: string): Observable<Contact> {
        return this.db.getContact(id).pipe(
            // Using the Contact class to transform
            // the server response into
            // an instance of type Contact
            map(response => Contact.createInstance(response))
        );
    }

    getContacts(): Observable<Contact[]> {
        return this.db.getContacts().pipe(
            // Using the Contact class to transform
            // the server response into
            // a list of Contact instances
            map(response => Contact.createList(response))
        );
    }
}

We now have a single location for defensive programming and for applying defaults
Note that as the server data is mapped, we are ensuring that we have no null values to cause reference errors (arrays will definitely be arrays), and we can apply sensible defaults.

Any server-side api changes can be handled in one place, and leave the rest of the UI code unaffected.
If the server-side devs decide to change their model to snake case. We only need to change the mapping in the class constructor. The rest of our codebase will remain unaffected and continue to use the original property names.

export class Contact {
    firstName: string;
    lastName: string;
    
    constructor(raw) {
        // the only code that needs to be changed
        // when the server-side model changes
        this.firstName = raw.first_name;
        this.lastName = raw.last_name;
    }
}

// component code doesn't require changing because it
// continues to use the original front-end model.
console.log(contact.firstName);

Let’s say that contact data is now also obtained from a second api. We simply provide a different mapping resulting in the same client-side model. No need to change component code.

export class Contact {
    firstName: string;
    lastName: string;
    
    public static createFromOriginalAPI(dbContact): Contact {
        dbContact = dbContact || {};
        dbContact.firstName = dbContact.first_name;
        dbContact.lastName = dbContact.last_name;
        return new Contact(dbContact);
    }

    public static createFromSalesforceAPI(sfContact): Contact {
        sfContact = sfContact || {};
        sfContact.firstName = sfContact.FirstName__c;
        sfContact.firstName = sfContact.LastName__c;
        return new Contact(sfContact);
    }

    constructor(raw) {
        this.firstName = raw.firstName;
        this.lastName = raw.lastName;
    }
}

// no matter the source of the data, our components
// continue to use our front-end model.
console.log(contact.firstName);

Data can be mapped to a more convenient shape
As outlined earlier in this post, some data is awkward to use without transforming it. This is again solved by mapping it (just once on entry to the front-end system) on object creation.

// Shape of data from server
{
    "contact": {
        "personal": {
            "first_name": "Tony",
            "last_name": "Toenails",
            "job_title": "Professional Contact"
        }
    }
}

export class Contact {
    firstName: string;
    lastName: string;
    jobTitle: string;

    constructor(raw) {
        // The data we want is extracted from a difficult
        // to use shape, and transformed into something
        // more convenient.
        this.firstName = raw?.contact?.personal?.first_name || '';
        this.lastName = raw?.contact?.personal?.last_name || '';
        this.jobTitle = raw?.contact?.personal?.job_title || '';
    }
}

Class properties and methods confer additional advantages over interfaces

Use class properties to reduce bugs and improve readability.
Component code can often get cluttered up with complex, data-based logic that is slower to read, difficult to discern the meaning of, and more prone to bugs.

// Original code to determine whether
// contact is a valid prospect for a
// marketing campaign
if(contact.address.country === 'UK'
    && (contact.industry === 'Accounting'
        || contact.industry === 'Bookkeeping')
    && contact.isBusinessOwner) {
    // Set up up sales call
}

Instead, using TypeScript class properties can really improve the readability and moves the logic to the class definition where it belongs (and is easier to test). Now if there were a change in what constitutes a valid prospect for marketing, there is only one place that the code needs to be updated.

export class Contact {
    // property definitions etc
    ...
	
    get isValidProspect() {
        return contact.address?.country === 'UK'
            && (contact.industry === 'Accounting'
                || contact.industry === 'Bookkeeping')
            && contact.isBusinessOwner;
    }
}

// simplified component code
if(contact.isValidProspect) {
    // Set up sales call
}

Last but not least, unit tests for classes are trivially easy to set up compared to component code.

It’s only human nature that the harder, or more time-consuming something is, the more it ends up being avoided. So moving logic to where it is easier to test is a no-brainer.

Firstly, component tests are more difficult to set up than a unit test for a class. It differs depending on the framework, but any amount of complexity is going to add a certain amount of friction to the testing process. Yet nothing could be easier than setting up basic example data and simply creating an instance of a class.

describe('isValidProspect property', () => {

    it('should return true for a valid prospect', () => {
		
        // Setup
        const data = {
            industry: "Accounting",
            isBusinessOwner: true,
            address: { country: 'UK' }
        };
	
        // Test
        const contact = Contact.createInstance(data);
	
        // Assert
        expect(contact.isValidProspect).toBe(true);
    });
});

Not only are the mechanics of setting up a unit test much easier, moving the logic into the class isolates it, so it is easy to provide precise inputs and precisely measure the output.

Conclusion

TypeScript classes can act as a layer decoupling the front-end model from the server.

  • Mapping incoming data protects the UI from changes outside the control of a front-end developer by limiting any changes to the mapper.
  • Defensive programming and defaults can also be set up in a single, centralised location.
  • Classes provide the additional functionality of properties and methods which keeps the code DRY and improves readability.
  • Moving data-related logic to classes isolates it, making unit tests reliable and trivially easy to set up.