The Service Now REST API is an API which allows you to interact with Service Now. It produces different shaped results based upon the sysparm_display_value
query parameter. This post looks at how we can model these API results with TypeScripts conditional types. The aim being to minimise repetition whilst remaining strongly typed. This post is specifically about the Service Now API, but the principles around conditional type usage are generally applicable.
The power of a query parameter
There is a query parameter which many endpoints in Service Nows Table API support named sysparm_display_value
. The docs describe it thus:
Data retrieval operation for reference and choice fields. Based on this value, retrieves the display value and/or the actual value from the database.
Valid values:
true
: Returns the display values for all fields.false
: Returns the actual values from the database.all
: Returns both actual and display value
Let's see what that looks like when it comes to loading a Change Request. Consider the following curls:
# sysparm_display_value=all
curl "https://ourcompanyinstance.service-now.com/api/now/table/change_request?sysparm_query=number=CHG0122585&sysparm_limit=1&sysparm_display_value=all" --request GET --header "Accept:application/json" --user 'API_USERNAME':'API_PASSWORD' | jq '.result[0] | { state, sys_id, number, requested_by, reason }'
# sysparm_display_value=true
curl "https://ourcompanyinstance.service-now.com/api/now/table/change_request?sysparm_query=number=CHG0122585&sysparm_limit=1&sysparm_display_value=true" --request GET --header "Accept:application/json" --user 'API_USERNAME':'API_PASSWORD' | jq '.result[0] | { state, sys_id, number, requested_by, reason }'
# sysparm_display_value=false
curl "https://ourcompanyinstance.service-now.com/api/now/table/change_request?sysparm_query=number=CHG0122585&sysparm_limit=1&sysparm_display_value=false" --request GET --header "Accept:application/json" --user 'API_USERNAME':'API_PASSWORD' | jq '.result[0] | { state, sys_id, number, requested_by, reason }'
When executed, they each load the same Change Request from Service Now with a different value for sysparm_display_value
. You'll notice there's some jq
in the mix as well. This is because there's a lot of data in a Change Request. Rather than display everything, we're displaying a subset of fields. The first curl has a sysparm_display_value
value of all
, the second false
and the third true
. What do the results look like?
sysparm_display_value=all
:
{
"state": {
"display_value": "Closed",
"value": "3"
},
"sys_id": {
"display_value": "4d54d7481b37e010d315cbb5464bcb95",
"value": "4d54d7481b37e010d315cbb5464bcb95"
},
"number": {
"display_value": "CHG0122595",
"value": "CHG0122595"
},
"requested_by": {
"display_value": "Sally Omer",
"link": "https://ourcompanyinstance.service-now.com/api/now/table/sys_user/b15cf3ebdbe11300f196f3651d961999",
"value": "b15cf3ebdbe11300f196f3651d961999"
},
"reason": {
"display_value": null,
"value": ""
}
}
sysparm_display_value=true
:
{
"state": "Closed",
"sys_id": "4d54d7481b37e010d315cbb5464bcb95",
"number": "CHG0122595",
"requested_by": {
"display_value": "Sally Omer",
"link": "https://ourcompanyinstance.service-now.com/api/now/table/sys_user/b15cf3ebdbe11300f196f3651d961999"
},
"reason": null
}
sysparm_display_value=false
:
{
"state": "3",
"sys_id": "4d54d7481b37e010d315cbb5464bcb95",
"number": "CHG0122595",
"requested_by": {
"link": "https://ourcompanyinstance.service-now.com/api/now/table/sys_user/b15cf3ebdbe11300f196f3651d961999",
"value": "b15cf3ebdbe11300f196f3651d961999"
},
"reason": ""
}
As you can see, we have the same properties being returned each time, but with a different shape. Let's call out some interesting highlights:
requested_by
is always an object which containslink
. It may also containvalue
anddisplay_value
depending uponsysparm_display_value
state
,sys_id
,number
andreason
are objects containingvalue
anddisplay_value
whensysparm_display_value
isall
. Otherwise, the value ofvalue
ordisplay_value
is surfaced up directly; not in an object.- most values are strings, even if they represent another data type. So
state.value
is always a stringified number. The only exception to this rule isreason.display_value
which can benull
Type Definition time
We want to create a type definitions for these API results. We could of course create three different results, but that would involve duplication. Boo! Also, we're looking at a subset of five properties in this example. In reality, there will be many, many properties and so duplication would become very impractical.
What can we do? Well, if all of the underlying properties were of the same type, we could use a generic and be done. But given the underlying types can vary, that's not going to work. We can achieve this though through using a combination of generics and conditional types.
Let's begin by creating a string literal type of the possible values of sysparm_display_value
:
export type DisplayValue = 'all' | 'true' | 'false';
Making a PropertyValue
type
Next we need to create a type that models the object with display_value
and value
properties.
:::info a type for state, sys_id, number and reason
state
,sys_id
,number
andreason
are objects containingvalue
anddisplay_value
whensysparm_display_value
is'all'
. Otherwise, the value ofvalue
ordisplay
is surfaced up directly; not in an object.- most values are strings, even if they represent another data type. So
state.value
is always a stringified number. The only exception to this rule isreason.display_value
which can benull
:::
export interface ValueAndDisplayValue<TValue = string, TDisplayValue = string> {
display_value: TDisplayValue;
value: TValue;
}
Note that this is a generic property with default values of string
for both display_value
and value
. most of the time, string
is the type in question so it's great that TypeScript allows us to cut down on the amount of syntax we use.
Now we're going to create our first conditional type:
export type PropertyValue<
TAllTrueFalse extends DisplayValue,
TValue = string,
TDisplayValue = string
> = TAllTrueFalse extends 'all'
? ValueAndDisplayValue<TValue, TDisplayValue>
: TAllTrueFalse extends 'true'
? TDisplayValue
: TValue;
The PropertyValue
will either be a ValueAndDisplayValue
, a TDisplayValue
or a TValue
, depending upon whether PropertyValue
is 'all'
, 'true'
or 'false'
respectively. That's hard to grok. Let's look at an example of each of those cases using the reason
property, which allows a TValue
of string
and a TDisplayValue
of string | null
:
const reasonAll: PropertyValue<'all', string, string | null> = {
"display_value": null,
"value": ""
};
const reasonTrue: PropertyValue<'true', string, string | null> = null;
const reasonFalse: PropertyValue<'false', string, string | null> = '';
Consider the type on the left and the value on the right. We're successfully modelling our PropertyValue
s. I've deliberately picked an edge case example to push our conditional type to its limits.
Service Now Change Request States
Let's look at another usage. We'll create a type that repesents the possible values of a Change Request's state
in Service Now. Do take a moment to appreciate these values. Many engineers were lost in the numerous missions to obtain these rare and secret enums. Alas, the Service Now API docs have some significant gaps.
/** represents the possible Change Request "State" values in Service Now */
export const STATE = {
NEW: '-5',
ASSESS: '-4',
SENT_FOR_APPROVAL: '-3',
SCHEDULED: '-2',
APPROVED: '-1',
WAITING: '1',
IN_PROGRESS: '2',
COMPLETE: '3',
ERROR: '4',
CLOSED: '7',
} as const;
export type State = typeof STATE[keyof typeof STATE];
By combining State
and PropertyValue
, we can strongly type the state
property of Change Requests. Consider:
const stateAll: PropertyValue<'all', State> = {
"display_value": "Closed",
"value": "3"
};
const stateTrue: PropertyValue<'true', State> = "Closed";
const stateFalse: PropertyValue<'false', State> = "3";
With that in place, let's turn our attention to our other natural type that the requested_by
property demonstrates.
Making a LinkValue
type
:::info a type for requested_by
requested_by
is always an object which contains link
. It may also contain value
and display_value
depending upon sysparm_display_value
:::
interface Link {
link: string;
}
/** when TAllTrueFalse is 'false' */
export interface LinkAndValue extends Link {
value: string;
}
/** when TAllTrueFalse is 'true' */
export interface LinkAndDisplayValue extends Link {
display_value: string;
}
/** when TAllTrueFalse is 'all' */
export interface LinkValueAndDisplayValue extends LinkAndValue, LinkAndDisplayValue {}
The three types above model the different scenarios. Now we need a conditional type to make use of them:
export type LinkValue<TAllTrueFalse extends DisplayValue> = TAllTrueFalse extends 'all'
? LinkValueAndDisplayValue
: TAllTrueFalse extends 'true'
? LinkAndDisplayValue
: LinkAndValue;
This is hopefully simpler to read than the PropertyValue
type, and if you look at the examples below you can see what usage looks like:
const requested_byAll: LinkValue<'all'> = {
"display_value": "Sally Omer",
"link": "https://ourcompanyinstance.service-now.com/api/now/table/sys_user/b15cf3ebdbe11300f196f3651d961999",
"value": "b15cf3ebdbe11300f196f3651d961999"
};
const requested_byTrue: LinkValue<'true'> = {
"display_value": "Sally Omer",
"link": "https://ourcompanyinstance.service-now.com/api/now/table/sys_user/b15cf3ebdbe11300f196f3651d961999"
}
const requested_byFalse: LinkValue<'false'> = {
"link": "https://ourcompanyinstance.service-now.com/api/now/table/sys_user/b15cf3ebdbe11300f196f3651d961999",
"value": "b15cf3ebdbe11300f196f3651d961999"
};
Making our complete type
With these primitives in place, we can now build ourself a (cut-down) type that models a Change Request:
export interface ServiceNowChangeRequest<TAllTrueFalse extends DisplayValue> {
state: PropertyValue<TAllTrueFalse, State>;
sys_id: PropertyValue<TAllTrueFalse>;
number: PropertyValue<TAllTrueFalse>;
requested_by: LinkValue<TAllTrueFalse>;
reason: PropertyValue<TAllTrueFalse, string, string | null>;
// there are *way* more properties in reality
}
This is a generic type which will accept 'all'
, 'true'
or 'false'
and will use that type to drive the type of the properties inside the object. And now we have successfully typed our Service Now Change Request, thanks to TypeScript's conditional types.
To test it out, let's take the JSON responses we got back from our curls at the start, and see if we can make ServiceNowChangeRequest
s with them.
const changeRequestFalse: ServiceNowChangeRequest<'false'> = {
"state": "3",
"sys_id": "4d54d7481b37e010d315cbb5464bcb95",
"number": "CHG0122595",
"requested_by": {
"link": "https://ourcompanyinstance.service-now.com/api/now/table/sys_user/b15cf3ebdbe11300f196f3651d961999",
"value": "b15cf3ebdbe11300f196f3651d961999"
},
"reason": ""
};
const changeRequestTrue: ServiceNowChangeRequest<'true'> = {
"state": "Closed",
"sys_id": "4d54d7481b37e010d315cbb5464bcb95",
"number": "CHG0122595",
"requested_by": {
"display_value": "Sally Omer",
"link": "https://ourcompanyinstance.service-now.com/api/now/table/sys_user/b15cf3ebdbe11300f196f3651d961999"
},
"reason": null
}
const changeRequestAll: ServiceNowChangeRequest<'all'> = {
"state": {
"display_value": "Closed",
"value": "3"
},
"sys_id": {
"display_value": "4d54d7481b37e010d315cbb5464bcb95",
"value": "4d54d7481b37e010d315cbb5464bcb95"
},
"number": {
"display_value": "CHG0122595",
"value": "CHG0122595"
},
"requested_by": {
"display_value": "Sally Omer",
"link": "https://ourcompanyinstance.service-now.com/api/now/table/sys_user/b15cf3ebdbe11300f196f3651d961999",
"value": "b15cf3ebdbe11300f196f3651d961999"
},
"reason": {
"display_value": null,
"value": ""
}
}
We can! Do take a look at this in the TypeScript playground.