Field masks are a way for API callers to list the fields that a request should return or update: read more about them here. When working with field masks, checks usually happen at run-time and through tests.
After some work, I figured out how to take advantage of TypeScript’s type system, so one can also leverage compile-time checks.
I wrote two snippets to showcase it:
- The first one retrieves only top-level fields of a structure (goto implementation)
let data = getPartialData(['id', 'settings'])
// data contains ONLY `id` and `settings` fields
- The second one fetches fields at a specified depth within a structure (goto implementation). This implementation uses advanced TS types
let data = getPartialData(['id', 'settings.premium', 'settings.preferences.language'])
// data contains ONLY `id`, `settings.premium` and `settings.preferences.language` fields
Internal workings are explained through comments added in code.
The structures defined below outline the data interface. These are used in both showcased methods
interface GetDataResponse {
id?: number;
profile?: Profile;
settings?: Settings;
}
interface Profile {
name: string | null;
age: number;
friends: Friend[];
}
interface Friend {
name: string;
hobbies: string[];
}
interface Settings {
premium: boolean;
preferences: Preferences;
}
interface Preferences {
notificationsEnabled: boolean;
language: string;
}
// Mocking a call to a server
function getDataFromServer(): GetDataResponse {
return {
id: 15,
profile: {
name: "username",
age: 25,
friends: [{
name: "friend",
hobbies: ["volleyball", "football"]
}]
},
settings: {
premium: false,
preferences: {
notificationsEnabled: true,
language: "en",
},
},
}
}
Top-level field masks
// getPartialData is a function that receives an array of fields and returns an object
// with the fields that were passed in the array
//
// Once familiar with TypeScript's type inference rules, the function's logic becomes
// straightforward to understand: `P extends keyof Type` contraints that P can only be
// keys from GetUserDataResponse.
// When you call `getPartialData(['id', 'settings'])`, TypeScript narrows down the type
// of P from the passed arguments. Since the array contains two elements that are keys
// of GetUserDataResponse, TypeScript infers `P` as the union type 'id' | 'settings'.
//
// After we infer P's type, we can use Pick<GetUserDataResponse, P> to pick the set of
// properties of GetUserDataResponse present in fieldMaskSets. Utilizing Required<Type>
// clarifies the response by eliminating the possibility of undefined values
function getPartialData<P extends keyof GetDataResponse>(
fieldMaskSets: P[]
): Pick<Required<GetDataResponse>, P> {
const dataResponse = getDataFromServer();
const res: any = {}; // ugly but gets the job done
fieldMaskSets.forEach((field) => {
res[field] = dataResponse[field];
})
return res;
}
Note: It’s common to assume that P here should be of type 'id' | 'settings' | 'profile'
instead. However, TypeScript narrows down the type based on actual usage:
When you declare a generic type parameter that extends a union of literal types, it means that the generic type can be any subset of them. When you use this generic type as a type for a function argument, TypeScript infers the specific type of the generic parameter based on that argument.
For example, if there is a generic type P that extends 'a' | 'b' | 'c'
, and a function argument that has type 'a' | 'b'
(which satisfies P
), TypeScript will deduce that generic type P is limited to 'a' | 'b'
Example:
// Defining an interface with the expected response for convenience.
// A compile error will occur if this interface does not align with
// the GetDataResponse interface
interface GetData {
id: number;
settings: Settings;
}
// No compile error
const data: GetData = getPartialData(['id', 'settings']);
data.id;
data.settings;
// Compile error: Did you mean '"profile"'?
// Getting unexisting field results in compile error
const compileError: GetData = getPartialData(['id', 'settings', 'prfoile']);
// |
// -----------------------------------------------------------------┘
// Compile error: Property 'profile' does not exist on type
// Accessing any field that was not retrieved results in compile error
getPartialData(['id', 'settings']).profile;
// |
// -----------------------------------┘
Deeply nested field masks
The main idea is:
- implement a utility type (
PathsOf<Type>
) that produces a string literal union of all possible property paths ofType
- implement a utility type (
DeepPick<Type, Paths>
) that constructs a type by picking the set of properties with pathsPaths
fromType
- use the same type narrowing approach as in the previous snippet to construct the result type from a list of paths
Both implemented utility types employ recursion to walk through the object structure and construct / pick the paths.
PathsOf<Type>
// Union of basic JS types
type Primitive = string | number | boolean | null | undefined;
// PathsOf<Type> produces a string literal union of all possible property paths of `Type`
//
// Example: PathsOf<{a: {b: number, c: string}, d: string}> = 'a' | 'a.b' | 'a.c' | 'd'
//
// Type - The main type for which property paths are being constructed
// Acc - This is an internal parameter used in recursive calls to build up the path string.
// It starts off as an empty string
type PathsOf<Type, Acc extends string = ''> =
// if Type is a primitive type, there are no properties to traverse
Type extends Primitive ? never :
// if Type is an array, then infer the type U of the array elements and call PathsOf
// for U
Type extends Array<infer U>
? PathsOf<U, Acc>
// map each key of Type to a union of paths
: { [K in keyof Type]-?:
// if a field is a string-keyed field
K extends string
// then compose the path using what we accumulated so far in Acc, '.' and K,
// and put it in AccK.
// if Acc is empty, then don't add a dot before K
? `${Acc extends '' ? '' : `${Acc}.`}${K}` extends infer AccK
// if Type[K] is a primitive or an array of primitives (i.e. leafs)
? Type[K] extends Primitive | Array<Primitive>
// then only construct the paths for what we accumulated so far in AccK
? AccK
// else continue traversing Type[K] with AccK as the new accumulator.
// Extract<AccK, string> isn't doing anything here, because AccK is composed
// of two types that extend string (Acc and K). I had to use it to assure
// TypeScript that AccK is a string
: PathsOf<Type[K], Extract<AccK, string>>
// we can never get here, but we have no other way to infer AccK
: never
// don't allow fields of Type that are not string-keyed
: never
// get all constructed paths for fields of Type, but don't allow empty strings
}[keyof Type] | (Acc extends '' ? never : Acc);
DeepPick<Type, Paths>
// Union of basic JS types
type Primitive = string | number | boolean | null | undefined;
// Head is the first part of a string before the first occurrence of '.'
//
// Example: Head<'a.b.c'> = 'a'
// Example: Head<'a.b.c' | 'b.c'> = 'a' | 'b'
type Head<T extends string> = T extends `${infer First}.${string}` ? First : T;
// Tail is the part of a string after the first occurrence of '.'
//
// Example: Tail<'a.b.c'> = 'b.c'
// Example: Tail<'a.b.c' | 'b.c'> = 'b.c' | 'c'
type Tail<T extends string> = T extends `${string}.${infer Rest}` ? Rest : never;
// DeepPick<Type, Paths> allows for deeply selective extraction of properties and their
// nested properties from an object type. It constructs a type by picking the set
// of properties' paths `Paths` from `Type`
//
// Example: DeepPick<{a: {b: number, c: string}, d: string}, 'a.b'> = {a: {b: number}}
//
// Union paths are treated like a combination of paths. For example, 'a' | 'b' would mean
// 'pick properties a and b'
// Example: DeepPick<{a: {b: number, c: string}, d: string}, 'a.b' | 'd'> =
// {a: {b: number}, d: string}
//
// Type - The main type from which properties are being picked
// Paths - The paths of the properties that are being picked
type DeepPick<Type, Paths extends string> =
// if Type is a primitive type, there are no properties to traverse.
// return Required<Type> to avoid optional types
Type extends Primitive ? Required<Type> :
// if Type is an array, infer the type U of the array elements and call DeepPick for U
Type extends Array<infer U>
? DeepPick<U, Paths>[]
: {
// traverse all fields of Type that are also in Head<Paths>
[P in Head<Paths> & keyof Type]:
// 'Extract<Paths, `${P}.${string}`>' filters out from Paths only those types
// that start with a specific property name (P) and are followed by
// another string separated by a dot.
// After filtering, we get the tail of the string (the part after the dot)
// and if it's empty, then we know that Type[P] is a leaf
Tail<Extract<Paths, `${P}.${string}`>> extends ''
// return Required<Type[P]> to avoid optional types
? Required<Type[P]>
// if the tail is not empty, then we can continue traversing Type[P]
// with the tail as the new path
: DeepPick<Type[P], Tail<Extract<Paths, `${P}.${string}`>>>
}
getPartialData
Finally, we can use the two utility types to implement the function that retrieves the data from the server. The function uses the same type narrowing approach as in the previous snippet to construct the result type from a list of paths.
// getPartialData is a function that takes a list of paths and constructs a new type
// that contains only the properties specified by the given paths.
//
// It uses the PathsOf type to construct all possible paths of GetDataResponseType's
// properties and then uses DeepPick to pick only the properties specified
// in fieldMasks
function getPartialData<Paths extends PathsOf<GetDataResponse>>(
fieldMasks: Paths[]
): DeepPick<Required<GetDataResponse>, Paths> {
const dataResponse = getDataFromServer();
// getNestedField is a helper function that traverses an object and returns the value
// of a property specified by a path.
function getNestedField(obj: any, path: string[]): any {
const [first, ...rest] = path;
const value = obj[first];
// if the value is an array and there are more paths to traverse,
// then map the array and call getNestedField for each item in the array
if (Array.isArray(value) && rest.length > 0) {
return value.map(item => getNestedField(item, rest));
}
return rest.length === 0 ? value : getNestedField(value, rest);
}
const res: any = {}; // ugly but gets the job done
// iterate all paths and populate the result object with the values
// of the corresponding properties
fieldMasks.forEach((field) => {
const path = field.split('.');
let current = res;
for (let i = 0; i < path.length - 1; i++) {
current[path[i]] = current[path[i]] || {};
current = current[path[i]];
}
current[path[path.length - 1]] = getNestedField(dataResponse, path);
})
return res
}
Example:
// Defining an interface with the expected response for convenience.
// A compile error will occur if this interface does not align with
// the GetDataResponse interface
interface GetData {
id: number;
profile: {
name: string | null,
friends: Friend[],
}
settings: {
preferences: Preferences,
}
}
// No compile error
const response: GetData = getPartialData([
"id",
"profile.name",
"profile.friends",
"settings.preferences",
]);
response.id;
response.profile.name;
response.settings.preferences.language;
// We can also get only a subset of the data for a type
interface HobbiesOfFriends {
profile: {
age: number;
friends: {
hobbies: string[]
}[]
}
}
// No compile error
const r: HobbiesOfFriends = getPartialData(['profile.age', 'profile.friends.hobbies']);
// Compile error: Property 'name' does not exist on type
// Accessing a field that was not requested will result in a compile error
getPartialData(['profile.age', 'profile.friends.hobbies']).profile.name;
// |
// -----------------------------------------------------------------┘
// Compile error: Did you mean '"profile.name"'?
// Getting a field that does not exist will result in a compile error
getPartialData(['profile.namee', 'profile.friends.hobbies']);
// |
// ------------------------┘