Favoring Local Interfaces Over Imported Interfaces For Data Structures In Angular 5.0.2
When I first started getting into Angular 2 with TypeScript, I had no idea how to handle TypeScript Interfaces for vanilla data-structures (ie, not Class-based types). I played around with importing Interfaces from parent Components or Services. I played around with moving Interfaces to a centralized "interfaces.ts" file. None of it felt very clean. And, I kept having to jump from file to file to remember what data was being expected in which context. At one point, when I was just trying to flesh out an idea for an app, I started "duplicating" paired-down Interfaces in the Components and Services that were receiving data. And suddenly, everything felt way easier! Favoring local interface over imported interfaces for data-structures has now become a pattern than I lean on quite frequently. It is not without its drawbacks, to be sure; but, it has made the overall development ergonomics much more pleasent.
Run this demo in my JavaScript Demos project on GitHub.
To illustrate what I mean about favoring local Interfaces over Imported interfaces for data-structures, I've created a simple AppComponent that provides a collection of typed data-structures, each of which will be rendered by an ItemComponent:
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export interface Item {
id: number;
name: string;
size: number;
createdAt: number;
}
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<ul>
<li *ngFor="let item of items">
<my-item [item]="item"></my-item>
</li>
</ul>
`
})
export class AppComponent {
public items: Item[];
// I initialize the app component.
constructor() {
this.items = [
{ id: 1, name: "One", size: 4, createdAt: Date.now() },
{ id: 2, name: "Two", size: 38, createdAt: Date.now() },
{ id: 3, name: "Three", size: 4, createdAt: Date.now() },
{ id: 4, name: "Four", size: 128, createdAt: Date.now() },
{ id: 5, name: "Five", size: 79, createdAt: Date.now() }
];
}
}
As you can see, the AppComponent is passing implementations of the Item{} interface into the ItemComponent. The ItemComponent could certainly turn around an import the "Item" interface from the AppComponent. But, instead, I've opted to define a local, pair-down interface for the Item data-structure directly within the ItemComponent:
// Import the core angular services.
import { Component } from "@angular/core";
// NOTE: I could have just imported the Item interface from the AppComponent; or, from
// some other centralized "interfaces" TypeScript file. But, that makes this component
// dependent on external things. Instead, I am going to define a local Item interface
// below. This decouples this component from the AppComponent; inverts the coupling;
// and, provides local documentation for what kind of data this class is expecting to
// receive as inputs.
// --
// CAUTION: This does remove some of the type-safety at the Angular component boundary
// since component input properties are not validated (currently).
// --
// import { Item } from "./app.component";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface Item {
id: number;
name: string;
// Notice that this version of "Item" DOES NOT DEFINE the "size" or "createdAt"
// properties (as defined in the AppComponent). That's because this component
// doesn't care about them and doesn't need to know about them. Locally, I am only
// defining the relevant properties as documentation.
}
@Component({
selector: "my-item",
inputs: [ "item" ],
host: {
"[title]": "( 'Item ID: ' + item.id + '.' )"
},
styleUrls: [ "./item.component.less" ],
template:
`
Hello, I am item {{ item.name }}.
`
})
export class ItemComponent {
public item: Item;
}
Here, not only am I creating a local Item interface; but, that interface is a paired-down, ItemComponent-oriented subset of properties when compared to the original Item interface. The two major benefits to this approach are that one, ItemComponent is now completed decoupled from the external world - the dependency has been inverted; and two, the ItemComponent becomes self-documenting in its requirements. This makes it much more enjoyable to maintain.
And, if we run this application in the browser, we can see that it works:
The major drawback of this approach is that I lose some cross-component data validation. Meaning, in this demo, if I remove "name" from the root data-structure, I'd get a runtime error (theoretically), not a compile-time error since TypeScript can't verify component input-bindings. However, this cross-component issue could easily be caught in a unit-test.
NOTE: This drawback only applies to Input bindings in a Directive because the bindings are template-driven. TypeScript will happily validate any data being passed programmatically across Component / Directive / Service boundaries within an application.
Now, if the data-structures fundamentally change, I will have to go into a number of components to make sure that their local Interfaces are still meaningful. And, you might consider that a drawback to this approach. But, I don't (consider that a drawback). I view that as a requirement to remain thoughtful about how data is being used in an application.
I'm still relatively new to TypeScript, so this approach may very well blow up in my face as my applications get more complex. But so far, leaning on local Interfaces over imported Interfaces - for data-structures - has made development and maintenance of my current TypeScript work so much more enjoyable.
Want to use code from this post? Check out the license.
Reader Comments
How about defining and exporting the "base" interface locally in the component and then import it in app.component.ts.
import { ItemInterface } from '@app/item/item.component.ts';
The component is still decoupled.
@Tobias,
Good thought, but that won't work. When I go to define the actual collection of items in the root component, TypeScript will complain that I am trying to assign properties to each object that don't exist on the Item interface. Having two interfaces works because one is a subset of the other and the code in the ItemComponent never attempts to use any properties outside of the local instance.
What about using pluck to derive the interface from a base interface?
@Jens,
I don't have too much practical experience with Pluck? Would that essentially use the type-definitions from the original interface, but only require the keys in the local interface?
That said, using Pluck would essentially spread the interface across two different files. I might be able to see which keys are being plucked locally; but, I'd still have to jump to another file to see what those types entail (though, for the most part, that will likely be obvious(ish) once you are familiar with the app). The thing I love most about the "partially duplicated" interface approach is that the one class doesn't have to care about the consuming class -- it owns its own behavior and presents its own interface.