3
votes

I'm subscribing to observable and unshift a new element into the resulting array which mutates data. How can I prevent data mutation on a service level by throwing exception if someone tries to mutate data instead of doing deep copy?

I have an angular service to get the list of states from API based on country code. It's a singleton service meaning same instance is shared between different modules and their components. I'm caching the response with ShareReplay(). When in my component I subscribe to the observable and mutate the result (by unshifting a new value to the array) the cached data gets mutated. Right now I'm doing deep copy on a result which is fine but I want my observable to throw exception if somebody tries to use the service and mutates the original value instead of doing deep copy. I also tried to make service return deep copy of the observable but that didn't work, guess lodash doesnt know how to deep copy observables, unless I ditch shareReplay(), implement my own ReplaySubject on service level and make next() method return deep copy of emitted value maybe ?

Service

@Injectable()
export class StatesService {

    private states$: Observable<State[]> = null;

    constructor(private _http: HttpService) {}

    getStatesFromCountry(countryCode3: string, freshData: boolean = false): Observable<State[]> {
        let code = Object.keys(CountryEnum)
            .filter((key: any) => isNaN(key))
            .find(key => key === countryCode3.toUpperCase());

        if(!code) return Observable.throw("Country Code was not recognized. Make sure it exists in CountryEnum.");

        let url: string = `api/states?country=${countryCode3}`;

        if (freshData) return this.getStates(url);

        return this.states$ ? this.states$ : this.states$ = this.getStates(url).shareReplay(1);
    }

    private getStates(url: string): Observable<State[]> {
        return this._http.get(url)
            .map(response => response)
            .catch(error => Observable.throw(<ApiError>JSON.parse(error)));
    }

}

Component

    private loadStates(): Subscription {
        const usaStates$ = this._statesService.getStatesFromCountry(CountryEnum[CountryEnum.USA]);
        const canStates$ = this._statesService.getStatesFromCountry(CountryEnum[CountryEnum.CAN]);

        return Observable.forkJoin(usaStates$, canStates$).subscribe(
            ([usaStates, canStates]) => {
                this.statesList = _.cloneDeep(usaStates.concat(canStates));

                //Here if I unshift without doing deep copy first, other 
                //components that are using this shared service will now 
                //receive a mutated array of states

                this.statesList.unshift(<State>{id: null, code: 'All'});
            },
            error => { ApiError.onError(error, this._notificationService, this._loaderService); }
        );
    }
1
Or you could simply not mutate the data. Instead of this.statesList.unshift(//..., do this.stateList = [{ id: null, code: 'All' }, ...usaStates, ...canStates}]. - mbojko
@mbojko thanks, this is a great suggestion but I can't guarantee that other developers consuming the service will do the same hence need a way to throw the exception - ihorbond
Before sharing, you could map the data into an object with custom setters that do that. - mbojko
@mbojko actually, your answer led me to do this .shareReplay(1).map(x => _.cloneDeep(x)); I think I'm going to stick with it that way I do deep clone on service level and don't have to force other developers to do it in their components, thanks! - ihorbond

1 Answers

2
votes

You should be able to use Object.freeze on the value (somewhere).

const test = [1,2,3,4];
Object.freeze(test);

test.unshift(0); // error will be thrown

Cannot add property 5, object is not extensible