DEV Community

János Kukoda
János Kukoda

Posted on

Future-Proofing Code: Type-Safe KeyPath and Value Validation for Evolving Client-Backend Systems

Future-proof code means that it is designed and written in such a way that it remains maintainable in the long term and can be adapted to new requirements with minimal changes.

Let's assume we have three business entities in TypeScript: a user, the teams associated with the user, and, for each team, a set of transformations. Each transformation has a name and texts.

export interface UserDTO {
    teams: { [teamId: string]: TeamDTO };
}

export interface TeamDTO {
    transformations: { [transformationId: string]: Transformation };
}

export interface Transformation {
    name: string;
    texts: { [id: string]: string };
}
Enter fullscreen mode Exit fullscreen mode

To modify these entities, we follow the command pattern. The modification parameters are wrapped in an object: the value, and the keyPath which indicates where the modification should occur, the type of modification operation is represented by the setValue keyword.

await auth.updateClientAndServerArray([
    {
        keyPath: ['teams', 'response', 'transformations', id, 'name'],
        value: 'some-name',
        operation: 'setValue',
    },
]);
Enter fullscreen mode Exit fullscreen mode

The advantage of this approach is that a single abstract manipulation method with conditional types can handle all future modifications of the business entities on both the client-side and server-side. On the backend, if you're using a document-based database with a similar enclosing structure for business entities, the modification can also be implemented using a single endpoint.

This approach ensures strong type safety, but it requires some conditional types.

We perform two operations: the first duplicates a transformation and sets a new name for the newly created transformation. The second example modifies only the transformation's name.

Type safety is ensured in two ways:

  1. If the keyPath points to an invalid location, a static error is generated.
  2. If the value doesn’t match the type expected at the keyPath, a type error occurs.

For example, in TypeScript, value assignments can be triggered like this:

<button
    onClick={async () => {
        const id = randomString(4);
        const _transformation = {
            ...transformation,
            name: transformation?.name + ' copy',
            texts: {}
        };
        await auth.updateClientAndServerArray([
            {
                keyPath: ['teams', 'response', 'transformations', id],
                value: _transformation,
                operation: 'setValue',
            },
        ]);
    }}
>
    Duplicate transformation
</button>
Enter fullscreen mode Exit fullscreen mode

Here, the implementation of the updateClientAndServerArray method itself is not shown, but it uses a recursive approach. The same method handles both duplication and name setting.

To update the name field, updateClientAndServerArray can be used like this:

await auth.updateClientAndServerArray([
    {
        keyPath: ['teams', 'response', 'transformations', id, 'name'],
        value: 'some-name',
        operation: 'setValue',
    },
]);
Enter fullscreen mode Exit fullscreen mode

The key advantage here is that if a new field or container is added to any business entity in the future, no new manipulation methods are needed. The modification can be carried out without requiring changes to the backend or the client, and type safety is preserved, ensuring that no invalid values can be assigned. This approach can handle any JSON type, including strings, numbers, objects, or arrays.

Type Definitions

These two types are used to ensure type safety. The first type recursively traverses the business entities to verify whether the provided key path is valid:

export type KeyPath<T> = T extends object
    ? {
        [K in keyof T]: T[K] extends (infer U)[]
        ? [K] | [K, number, ...KeyPath<U>] // Arrays
        : T[K] extends Record<string, infer V>
        ? [K] | [K, string] | [K, string, ...KeyPath<V>] // Objects
        : T[K] extends object
        ? [K] | [K, ...KeyPath<T[K]>]
        : [K]; // Simple fields
    }[keyof T]
    : never;
Enter fullscreen mode Exit fullscreen mode

Next, we determine the value type associated with a specific key path:

export type ValueForKeyPath<T, P extends KeyPath<T>> =
    P extends [infer K, ...infer Rest]
    ? K extends keyof T
        ? Rest extends []
            ? T[K] // Return current value
            : T[K] extends (infer U)[]
            ? Rest extends [number, ...infer R]
                ? ValueForKeyPath<U, R extends KeyPath<U> ? R : never>
                : never
            : T[K] extends Record<string, infer U>
            ? Rest extends [string, ...infer R]
                ? ValueForKeyPath<U, R extends KeyPath<U> ? R : never> | U
                : U
            : T[K] extends object
            ? ValueForKeyPath<T[K], Rest extends KeyPath<T[K]> ? Rest : never>
            : never
        : never
    : never;
Enter fullscreen mode Exit fullscreen mode

If the value being assigned doesn’t match the type derived from the key path, a static error is raised, ensuring that only valid data can be assigned to the given key path.

For example:

await auth.updateClientAndServerArray([
    {
        keyPath: ['teams', 'response', 'transformations', id, 'name'],
        value: 'some-name',
        operation: 'setValue',
    },
]);
Enter fullscreen mode Exit fullscreen mode

This type-safe approach ensures that modifying nested structures is both flexible and reliable, providing a solid foundation for future-proofing your code.

Top comments (0)