TypeScript PickPaths: enhance type safety in data mappings

Pick nested paths with dot notation

Alberto González
Softonic Engineering
8 min readMar 27, 2024

--

The problem

Say I have a type to describe the shape of the data that I can fetch from a document database (a model).

From this document database I can fetch documents with partial data (a subset of properties) that I will use afterwards to map to an entity.

Depending on the situation I would require a different subset of the properties from the model to hydrate a given entity. Reasons behind this are mostly around efficiency (I don’t want to waste memory or bandwidth).

Let’s use the following example to illustrate the context of the problem:

interface ProgramModel {
id: string;
name: string;
description: string;
stats: {
views: number;
downloads: number;
}
files: {
url: string;
name: string;
}[]
}

class FullProgramEntity {
protected id: string;
protected name: string;
protected description: string;
protected totalDownloads: number;
protected totalViews: number;
protected downloadUrl: string;

static fromModel(programModel: ProgramModel): FullProgramEntity {
const newProgram = new FullProgramEntity();
newProgram.id = programModel.id;
newProgram.name = programModel.name;
newProgram.description = programModel.description;
newProgram.totalDownloads = programModel.stats.downloads;
newProgram.totalViews = programModel.stats.views;
newProgram.downloadUrl = programModel.files[0].url;
return newProgram;
}
}

class TinyProgramEntity {
protected id: string;
protected name: string;
protected totalDownloads: number;

static fromModel(programModel: ProgramModel): TinyProgramEntity {
const newProgram = new TinyProgramEntity();
newProgram.id = programModel.id;
newProgram.name = programModel.name;
newProgram.totalDownloads = programModel.stats.downloads;
return newProgram;
}
}

A part from the described above there’s a couple of details worth mention in the example about the ProgramModel type:

  • It contains nested properties.
  • It contains arrays with nested properties.

It’s quite obvious when I want to get a TinyProgramEntity I don’t need all the properties in ProgramModel but only a subset of them. From the typing perspective this is not a problem, but if I’m to fetch only this subset from the database I might need to define a type that represents just that, so I’m not using a property by mistake.

Let’s assume I can fetch the properties from a document database that implements this schema by using the properties paths, e.g.. id name description stats.views stats.downloads files.url (yes, even the array nested props) …

If I have the full ProgramModel and a given list of paths, then I should be able to get the derived type with only a subset of those props. Something like:

const tinyProgramPaths = ['id', 'name', 'stats.downloads'] as const;

type TinyProgramModel = PickPaths<ProgramModel, typeof tinyProgramPaths[number]>;

PickPaths is the generic type we don’t have yet. TypeScript provides the built-in Pick generic type which you can use in a similar way. The construct typeof const_array[number] is how we transform the array of paths in a union type (which is what Pick requires and PickPaths will).

TinyProgramModel would now contain only the props defined by tinyProgramPaths, and we can use the same array as part of the query we will be sending to the database, so we don’t have to repeat it and everything is aligned.

The solution

I’m going to show the solution straight away, and later I’ll go through the details. But 1st of all I want to mention another article, which not only serves as an inspiration for this article structure, but it was the reason I didn’t give up working on this approach:

interface ProgramModel {
id: string;
name: string;
description: string;
stats: {
views: number;
downloads: number;
}
files: {
url: string;
name: string;
}[]
}

type PickPathsUnion<T, Paths extends string> = T extends Array<infer U>
? PickPathsUnion<U, Paths>[]
: Paths extends `${infer Head}.${infer Tail}`
? Head extends keyof T
? { [K in Head]: PickPathsUnion<T[K], Tail> }
: never
: Paths extends keyof T
? { [K in Paths]: T[K] }
: never;

type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (
x: infer R
) => any
? R
: never;

type PickPaths<T, Paths extends string> = UnionToIntersection<PickPathsUnion<T, Paths>>;

const tinyProgramPaths = ["id", "name", "stats.downloads"] as const;

type TinyProgramModel = PickPaths<ProgramModel, (typeof tinyProgramPaths)[number]>;

class TinyProgramEntity {
protected id: string;
protected name: string;
protected totalDownloads: number;

static fromModel(programModel: TinyProgramModel): TinyProgramEntity {
const newProgram = new TinyProgramEntity();
newProgram.id = programModel.id;
newProgram.name = programModel.name;
newProgram.totalDownloads = programModel.stats.downloads;
return newProgram;
}
}

For the sake of simplicity I only left the part of the original code that is relevant. Now we can see the TinyProgramModel in use, and the definition of PickPaths, which I’m going to describe in the next section.

From the bottom up

As you can see in the last code snippet, PickPaths is a combination of 2 types:

  • PickPathsUnion(I named it this way to highlight the need of another type on top of it).
  • UnionToIntersection which is copied as is from the article linked in the previous section.

PickPathsUnion

PickPathsUnion<T, Paths extend string>is a conditional type that checks the structure of T and Paths to determine the resulting type with the following process:

  • If T is an array (T extends Array<infer U>), the utility applies itself recursively to each element type U within the array, forming an array of the resulting types.
  • If Paths is a nested path (e.g., 'stats.downloads'), it is split into Head (the property name before the dot) and Tail (the remaining path). The utility then checks if Head exists as a key in T.
  • If Head exists in T, the utility applies itself recursively to the property specified by Head, narrowing down the path to Tail.
  • If Head does not exist in T, the resulting type is never, indicating that the path is invalid.
  • If Paths is a single property name (e.g., 'id'), the utility checks if it exists as a key in T.
  • If the property exists in T, the resulting type includes only that property.
  • If the property does not exist in T, the resulting type is never.

This type returns a union of the resulting types derived from each path, thus the need for the type described next.

UnionToIntersection

Disclaimer: I’m not a TypeScript expert, so my summary below might be incorrect or incomplete. If you really are interested in the details I highly recommend reading the article from what I copied this type (linked above).

The UnionToIntersection<T> type employs nested conditional types, utilising the infer keyword to transform a union type T into an intersection type. By leveraging TypeScript's treatment of naked types and contra-variant type positions, it dynamically creates an intersection of all constituents in the union, effectively converting a union of types into an intersection. This process involves wrapping and unwrapping the type T via a function type, resulting in TypeScript inferring a new type R and returning the intersection of all inferred types.

  • Naked Types: If a generic type (T) is used without being wrapped in another type within a conditional type, TypeScript distributes the conditional across each member of a union if T is a union type.
  • Contravariant Type Positions: Function argument positions are contravariant. This means you can’t assign a function with a more specific argument type to a variable expecting a function with a more general argument type.

Bonus track: optional properties

As long as you don’t have, or you don’t care about having, optional properties in the original model (ProgramModel in this example), you are fine. Otherwise you have a problem, because the current implementation of PickPathsUnion always makes a nested property required, even if it was optional in the original type. This happens because when you split the path (e.g., "stats.downloads") and recurse, there's no way to distinguish whether the intermediate element ("stats") was originally optional or not.

To solve this issue, we can introduce another conditional type check, see the following documentation to understand the TypeScript concepts behind it:

Here is the full updated code snipped (the relevant part of it):

interface ProgramModel {
id: string;
name: string;
description: string;
stats?: {
views: number;
downloads: number;
};
files: {
url: string;
name: string;
}[];
}

type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (
x: infer R
) => any
? R
: never;

export type PickPathsUnion<T, Paths extends string> =
T extends Array<infer U>
? PickPathsUnion<U, Paths>[]
: Paths extends `${infer Head}.${infer Tail}`
? Head extends keyof T
? undefined extends T[Head]
? { [K in Head]?: PickPathsUnion<T[K], Tail> }
: { [K in Head]: PickPathsUnion<T[K], Tail> }
: never
: Paths extends keyof T
? undefined extends T[Paths]
? { [K in Paths]?: T[K] }
: { [K in Paths]: T[K] }
: never;

type PickPaths<T, Paths extends string> = UnionToIntersection<
PickPathsUnion<T, Paths>
>;

const tinyProgramPaths = ["id", "name", "stats.downloads"] as const;

type TinyProgramModel = PickPaths<
ProgramModel,
(typeof tinyProgramPaths)[number]
>;

class TinyProgramEntity {
protected id: string;
protected name: string;
protected totalDownloads: number;

static fromModel(programModel: TinyProgramModel): TinyProgramEntity {
const newProgram = new TinyProgramEntity();
newProgram.id = programModel.id;
newProgram.name = programModel.name;
newProgram.totalDownloads = programModel.stats?.downloads ?? 0;
return newProgram;
}
}

As you can see now we are required to check whether the expression for the total downloads is nullish or not and provide a fallback value (0 in this case).

How it works?

The key aspect of the solution is to put “undefined extends T[Head]” and not the other way around. This is because conditional types apply individually across union types. For example((string | undefined) extends undefined ? 1 : 0), the evaluation happens separately for each component within the union, before being recombined into a union of the results. This means the expression is broken down into (string extends undefined ? 1 : 0) | (undefined extends undefined ? 1 : 0), which then simplifies to 0 | 1.

Limitations

The output of the PickPaths type is going to be an intersection type of the properties (and nested properties) described in the paths.

Intersection types in TS have some limitations I’m not going to go through in this article, but the truth is, if in the example you pick “files.name” and “files.url” as properties you’ll end up with a type like “{ files: { url: string; }[];} & { files: { name: string; }[];}”. If you try to map this array, TypeScript would infer the most accurate type for the callback. Due to how TypeScript handles distributive types, it focuses on the first part of the intersection and might not consider all possibilities.

Summary

In this article, we have created a new TypeScript generic type, similar to the built-in Pick utility type but working with paths instead property names (which limits you to the 1st level properties of a type).

The PickPaths type allows you to pick nested paths from a given type using dot notation, even traversing arrays nested types.

In the example, we’ve seen how we can use an array of paths (which you might use to query your database), and full model description type, which you can combine to safely map data to an entity, ensuring (given the model complies with what there’s in the database) type safety.

--

--