If we have these three business entities in TypeScript:
export interface UserDTO {
teams: { [teamId: string]: TeamDTO };
}
export interface TeamDTO {
transformations: { [transformationId: string]: Transformation };
}
export interface Transformation {
name: string;
texts: { [id: string]: string };
}
To modify these, we follow the command pattern. The modification parameters are wrapped in the value
, and the key path indicates where the modification occurs. The type of modification here is represented by the setValue
operation.
The advantage of this approach is that a single abstract method can handle all future modifications on the client-side. On the backend, if you're using a document-based database with a similar representation of business entities as on the client-side, the modification can also be implemented with a single endpoint.
This approach is strongly type-safe.
We perform two operations: the first duplicates a transformation and sets a new transformation in an object, and the second example overrides the transformation's name.
Type safety appears in two ways:
- If the
keyPath
points to an invalid location, a static error is thrown. - If the value doesn’t match the type expected at the
keyPath
, a type error is generated.
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>
To update just the name field:
await auth.updateClientAndServerArray([
{
keyPath: ['teams', 'response', 'transformations', id, 'name'],
value: 'some-name',
operation: 'setValue',
},
]);
Type Definitions
These two types are used to ensure type safety. The first 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>] // Records
: T[K] extends object
? [K] | [K, ...KeyPath<T[K]>] // Objects
: [K]; // Simple fields
}[keyof T]
: never;
Next, we determine the value type associated with the 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;
If the value 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',
},
]);
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)