The other day, my team had an issue with an API endpoint where we were expecting a certain payload type, and we weren't receiving an object that differed from the contract we had developed/expected. Our team decided it was more efficient to adapt our handler than to update the API endpoint, we worked to discover what the contracts really were, and how to handle the new response. tl;dr: demo source code |lodash typings.

We were using Angular's HttpClient to make a get request. Their typings include 15 overloads, but we were using...any. It looked like this:

// this.http is the HttpClient
const response: Observable<any> = this.http.get<any>(url);

// use _.get to find the first item in the data array
const needThisValue = _.get( response, 'data.0', {} );

In the midst of this, I realized that the typings for _.get method was way more detailed than I knew how to deal with at the time, specifically in regards to typings. So much so, that I decided to list out some of the examples of how to use _.get that helped me work on debugging this particular issue. The rest of this post will be going through the various overloads (getting more general as we go down).


Demo

First, let's define some types to use for the rest of the demo

interface Person {
  name: string;
  age: number;
  children?: Person[];
}

declare type DriverType = "Race" | "Commercial" | "Personal";

interface Driver extends Person {
  type: DriverType;
  car: Car;
}
interface Car {
  make: string;
  model: string;
  year: number;
}

Next, we go ahead and create a few constants that match up with those types

const person: Person = {
  name: "Bob",
  age: 41
};

const civic: Car = {
  make: "Honda",
  model: "Civic",
  year: 2000
};

const bobDriver: Driver = {
  ...person,
  type: "Personal",
  car: civic
};

// some default values for later testing
const anyObj: any = {
  really: 'yes'
};
let undefinedVar: undefined = undefined;
let nullVar: null = null;

Now we can get to work with the first batch of demos.

import { get } from "lodash-es";
/*
  get<TObject extends object, TKey extends keyof TObject>(
    object: TObject,
    path: TKey | [TKey]
  ): TObject[TKey];
*/
const tObjProp: Car = get<Driver, "car">(bobDriver, "car");

/**
  get<TObject extends object, TKey extends keyof TObject>(
    object: TObject | null | undefined,
    path: TKey | [TKey]
  ): TObject[TKey] | undefined;
 */
const tObjOrNull: Car | undefined = get<Driver, "car">(bobDriver, "car");


Now for the next couple overloads, we deal with a new type: TDefault. This allows us to pass in what the default value should look like.


interface ToyCar {
  brand: string;
}
const hotWheel = {
  brand: 'Hotwheels'
};

/**
  get<TObject extends object, TKey extends keyof TObject, TDefault>(
    object: TObject | null | undefined,
    path: TKey | [TKey],
    defaultValue: TDefault
  ): TObject[TKey] | TDefault;
 */
const withDefault: Car = get<Driver, "car", Car>(bobDriver, "car", {
  make: "Ford",
  model: "Mustang",
  year: 2018
});

const defaultOtherType: Car | ToyCar = get<Driver, "car", ToyCar >(bobDriver, "car", hotWheel);


Numeric Dictionaries
Now we get into another interesting case, where we have a NumericDictionary (defined as an object whose keys are numbers, and whose values are <T>). So let's initialize one of those...


// make another car
const forester: Car = {
  make: 'Subaru',
  model: 'Forester',
  year: 2015
};

// make the dictionary
const carDict: NumericCarDict<Car> = {
  0: civic,
  1: forester
};


/**
  get<T>(
    object: NumericDictionary<T>,
    path: number
  ): T;
 */
const dictGet: Car = get<Car>(carDict, 0);

// array works here too
const dictGetArr: Car = get<Car>([civic], 0);

/**
  get<T>(
    object: NumericDictionary<T> | null | undefined,
    path: number
  ): T | undefined;
 */
const dictGetNull: Car | undefined = get<Car>(carDict, 4);
const dictGetNull2: Car | undefined = get<Car>(nullVar, 4);
const dictGetNull3: Car | undefined = get<Car>(undefinedVar, 4);

/**
  get<T>(
    object: NumericDictionary<T> | null | undefined,
    path: number,
    defaultValue: TDefault
  ): T | TDefault;
 */
const dictGetWithDefault: Car | ToyCar = get<Car, ToyCar>(carDict, 4, hotWheel);
const dictGetWithDefault2: Car | ToyCar = get<Car, ToyCar>(nullVar, 4, hotWheel);
const dictGetWithDefault3: Car | ToyCar = get<Car, ToyCar>(undefinedVar, 4, hotWheel);

Null Defaults with null or undefined objects
Sometimes we have an object that can be either null or undefined (as part of a response, or other interface), so lodash defines some ways of handling the same above implementations, just with possibly null values.

/*
  get<TDefault>(
    object: null | undefined,
    path: PropertyPath,
    defaultValue: TDefault
  ): TDefault;
*/
const alwaysDefaultNull: ToyCar = get<ToyCar>(nullVar, 'brand', hotWheel);
const alwaysDefaultUndefined: ToyCar = get<ToyCar>(undefinedVar, 'brand', hotWheel);


/**
get(
    object: null | undefined,
    path: PropertyPath
): undefined;
*/
const alwaysUndefinedNull: undefined = get(nullVar,'property.path.to.thing');
const alwaysUndefined: undefined = get(undefinedVar,'property.path.to.thing');


/**
  get(
    object: any,
    path: PropertyPath,
    defaultValue?: any
  ): any;
 */
const getAny: any = get(anyObj, 'really', 104); // nothing matters anymore

Summary

This is just some of my troubleshooting combined with hopefully a little more information than just the annotations. I didn't totally understand how they were working just from the typings, and doing these practical applications helped me. Hope it helps you!

-CL