TypeScript Seleccionar MUCHAS Propiedades Por Notación de Puntos
Tengo esta función desordenada y hecha a medias en JavaScript que te permite elegir propiedades de un objeto usando la notación de puntos:
const pickObjProps = (obj,paths)=>{
let newObj = {}
paths.forEach((path)=>{
const value = path.split('.').reduce((prev,curr)=>{
return prev ? prev[curr] : null;
}
, obj || self);
function buildObj(key, value) {
var object
var result = object = {};
var arr = key.split('.');
for (var i = 0; i < arr.length - 1; i++) {
object = object[arr[i]] = {};
}
object[arr[arr.length - 1]] = value;
return result;
}
newObj = Object.assign(newObj, {
...buildObj(path, value)
})
}
)
return newObj
}
const obj = {
primaryEmail: "[email ",
suspended: false,
id: 'aiojefoij23498sdofnsfsdfoij',
customSchemas: {
Roster: {
prop1: 'val1',
prop2: 'val2',
prop3: 'val3'
}
},
some: {
deeply: {
nested: {
value: 2345945
}
}
},
names: {
givenName: 'Mocha',
familyName: 'Banjo',
fullName: 'Mocha Banjo'
},
phones: [{
type: 'primary',
value: '+1 (000) 000-0000'
}]
}
const result = pickObjProps(obj, ['primaryEmail', 'customSchemas.Roster', 'some.deeply.nested',])
console.log(result)La función funciona como pretendo. Sin embargo, quiero escribir la función en TypeScript y me está resultando muy difícil.
Me encontré con otra publicación que me dio una idea de cómo posiblemente escribirlo:
type PickByDotNotation<TObject, TPath extends string> =
TPath extends `${infer TKey extends keyof TObject & string}.${infer TRest}` ?
PickByDotNotation<TObject[TKey], TRest> :
TPath extends keyof TObject ?
TObject[TPath] :
never
Al intentar escribir la función, estoy tratando de crear un nuevo tipo llamado PickManyByDotNotation que toma dos argumentos genéricos:
- Un objeto
- Un arreglo de cadenas
Esto es lo lejos que he llegado:
type PickManyByDotNotation<TObject, TPaths extends string[]> = TPaths extends [
infer TKey extends string,
infer TRest extends string[],
]
? PickManyByDotNotation<PickByDotNotation<TObject, TKey>, TRest>
: TPaths extends string
? PickByDotNotation<TObject, TPaths>
: never
type PickByDotNotation<TObject, TPath extends string> =
// Constraining TKey so we don't need to check if its keyof TObject
TPath extends `${infer TKey extends keyof TObject & string}.${infer TRest}`
? PickByDotNotation<TObject[TKey], TRest>
: TPath extends keyof TObject
? TObject[TPath]
: never
La idea sería usar el tipo tal como:
interface Test {
customer: {
email: string;
name: string;
phone: string;
id: string
};
};
type PickMany = PickManyByDotNotation<Test, ['customer.email', 'custom.name']>
// which would theoretically return something like:
//
// customer: {
// email: string
// name: string
// }
Me estoy arrancando el cabello en este momento y en realidad estoy muy avergonzado de estar publicando.
Si pudieras ayudarme a terminar el tipo PickManyByDotNotation o posiblemente darme alguna idea sobre cómo tipar correctamente la función, te estaría muy agradecido.
Respuesta #1
El objetivo aquí es escribir un tipo PickByDotNotation<T, K> donde T sea algún tipo de objeto y K sea un tipo literal de cadena que corresponda a una ruta con puntos en T, o posiblemente una unión de tales tipos de rutas con puntos. Y la salida sería un supertipo de T que contenga solo las rutas que comiencen con las mencionadas en K.
Una vez que existe tal tipo, puedes darle a pickObjProps() una firma de llamada como
declare function pickObjProps<T extends object, K extends string>(
obj: T, paths: K[]
): PickByDotNotation<T, K>
Algunas advertencias/exclusiones de responsabilidad para aclarar desde el principio:
No me preocupa la implementación de
pickObjProps()ni lograr que el compilador verifique que la implementación cumpla con la firma de la llamada. Supongo que la implementación es correcta (o si no lo es, está fuera de alcance aquí) y que usarás cualquier medio necesario para que compile con la firma de la llamada (como usar aserciones de tipo o una sobrecarga de firma de llamada única, etc).No me preocupa lo que ocurra si
Ktiene alguna entrada no válida; si escribespickObjProps({a: 1, b: 2}, ["b", "c"])será aceptado y producirá un valor de tipo{b: number}.Solo me preocupa realmente los tipos de objetos no recursivos para
T, sin firmas de índice y sin propiedades opcionales, y sin propiedades con tipo unión, y sin intentar indexar en arrays con propiedades numéricas comophones.0.typeophones[0].type. Probé mi código con la llamada de ejemplo en la pregunta y eso es todo. Hay muchas complicaciones posibles con los tipos, y dado quePickByDotNotation<T, K>es necesariamente profundamente recursivo, es bastante posible que mi implementación haga algo inesperado frente a algunos de estos. Tales problemas a menudo pueden solucionarse, pero arreglarlos a veces puede requerir una refactorización completa. ¡Así que cuidado!
Entonces, mi implementación se ve así:
type PickByDotNotation<T, K extends string> = {
[P in keyof T as P extends (
K extends `${infer K0}.${string}` ? K0 : K
) ? P : never]:
P extends K ? T[P] : PickByDotNotation<
T[P], K extends `${Exclude<P, symbol>}.${infer R}` ? R : never
>
} & {}
Estoy utilizando la reasignación de claves en tipos mapeados para filtrar las claves de T. Un tipo mapeado de la forma {[P in keyof T as ⋯ extends ⋯ ? P : never]: ⋯} incluirá todas las claves donde el tipo condicional ⋯ extends ⋯ sea verdadero, y suprimirá todas las claves donde sea falso.
En lo anterior, la comprobación es si la clave P se extiende K extends `$ {infer K0}.$ {string}` ? K0 : K. Este es un tipo de literal de plantilla que analiza K para obtener la parte antes del primer punto, si hay un punto. Entonces, si K es "foo" | "bar.baz" | "bar.qux" | "blah.yuck", entonces K extends `$ {infer K0}.$ {string}` ? K0 : K será "foo" | "bar" | "blah". Así que estamos manteniendo todas las claves que aparecen como la primera parte de las rutas en K, y suprimiendo todas las claves que no.
En cuanto al tipo de valor de la propiedad, eso es P extends K ? T[P] : PickByDotNotation<T[P], K extends `$ {Exclude<P, symbol>}.$ {infer R}` ? R : never>. Si P resulta ser el mismo que K, entonces significa que esto no está punteado y simplemente devolvemos el tipo de propiedad T[P] como un Pick normal. De lo contrario, K está punteado y queremos tomar la parte después del punto y hacer una llamada recursiva a PickByDotNotation con T[P] como el nuevo objeto. La parte después del punto es K extends `$ {Exclude<P, symbol>}. $ {infer R}` ? R : nunca>. (Estoy usando el tipo utilitario Exclude para ignorar la posibilidad de que P sea de tipo symbol, que no puede tratarse como una cadena.) Entonces, si K es "foo" | "bar.baz" | "bar.qux" | "blah.yuck" y P es "bar", entonces K extends `$ {Exclude<P, symbol>}.$ {infer R}` ? R : nunca> será "baz" | "qux".
Eso es casi todo. Agregué una intersección con el tipo de objeto vacío {}, pero eso es solo por fines estéticos. Si lo dejas fuera, el compilador mostrará que el tipo de salida de pickObjProps() se mostrará en la información rápida de IntelliSense como PickByDotNotation<{⋯}, "⋯" | "⋯" | "⋯" | ⋯>, lo cual no es lo que la gente quiere ver. Al escribir & {} se hace que la visualización del tipo se evalúe completamente y obtienes las claves y valores escritos.
Vale, probémoslo en
const obj = {
primaryEmail: "[email ",
suspended: false,
id: 'aiojefoij23498sdofnsfsdfoij',
customSchemas: {
Roster: {
prop1: 'val1',
prop2: 'val2',
prop3: 'val3'
}
},
some: {
deeply: {
nested: {
value: 2345945
}
}
},
names: {
givenName: 'Mocha',
familyName: 'Banjo',
fullName: 'Mocha Banjo'
},
phones: [{
type: 'primary',
value: '+1 (000) 000-0000'
}]
}
const result = pickObjProps(obj,
['primaryEmail', 'customSchemas.Roster', 'some.deeply.nested']
);
El tipo de result se puede ver como
/* const result: {
primaryEmail: string;
customSchemas: {
Roster: {
prop1: string;
prop2: string;
prop3: string;
};
};
some: {
deeply: {
nested: {
value: number;
};
};
};
} */
Se ve bien. Solo las propiedades cuyos caminos comienzan con los elementos de ese arreglo están presentes en el tipo de salida.
Enlace de Playground al código
Respuesta #2
Esto realmente no responde a tu pregunta, pero yo había intentado resolver el mismo problema antes, y también elegí la notación de puntos al principio. Pero necesitaba cambiar la nulabilidad de algunas propiedades, es decir, algunas propiedades eran opcionales en el tipo base pero deberían ser obligatorias en el tipo elegido, y viceversa, lo cual es engorroso de expresar en notación de puntos. Terminé usando una especie de notación de objetos y creo que puede ser útil. Usar notación de objetos también ofrece mejor soporte de autocompletado/intellisense, evita errores tipográficos y evita tener que repetir el nombre de la propiedad externa para las propiedades anidadas.
Así es como lo implementé. Tenga en cuenta que en mi caso de uso, la nulabilidad de las propiedades del tipo destino no se hereda del tipo original, sino que se define en el objeto; es posible que tenga que modificar un poco si no es lo que desea.
import { SetOptional } from 'type-fest'
type PickDeepBaseOptions<T = unknown> = {
$required?: readonly (keyof T)[]
$optional?: readonly (keyof T)[]
}
type PickDeepOptions<T> = T extends unknown[] ? PickDeepOptions<T[number]> :
PickDeepBaseOptions<T> & { [K in keyof T]?: PickDeepOptions<T[K]> }
type SetOptionalLax<BaseType, Keys> = SetOptional<BaseType, Keys & keyof BaseType>
type RequiredKeys<O> = O extends { $required: readonly string[] } ? O['$required'][number] : never
type OptionalKeys<O> = O extends { $optional: readonly string[] } ? O['$optional'][number] : never
type KeysToTransform<O> = Exclude<keyof O, keyof PickDeepBaseOptions>
type KeysToCopy<O> = Exclude<RequiredKeys<O> | OptionalKeys<O>, KeysToTransform<O>>
type PickDeep<T, O> =
T extends unknown[] ? PickDeep<T[number], O>[] :
SetOptionalLax<
{
[K in KeysToCopy<O> & keyof T]: NonNullable<T[K]>
} & {
[K in KeysToTransform<O> & keyof T]: PickDeep<NonNullable<T[K]>, O[K]>
},
OptionalKeys<O>
>
/* ===== Usage: ===== */
const obj = {
primaryEmail: '[email ',
suspended: false,
id: 'aiojefoij23498sdofnsfsdfoij',
customSchemas: {
Roster: {
prop1: 'val1',
prop2: 'val2',
prop3: 'val3'
}
},
some: {
deeply: {
nested: {
value: 2345945
}
}
},
names: {
givenName: 'Mocha',
familyName: 'Banjo',
fullName: 'Mocha Banjo'
},
phones: [{
type: 'primary',
value: '+1 (000) 000-0000'
}]
}
const pickOPtions = {
$required: ['primaryEmail'],
$optional: ['phones'],
customSchemas: { $required: ['Roster'] },
some: { deeply: { $required: ['nested'] }},
phones: { $optional: ['value'] },
} satisfies PickDeepOptions<typeof obj>
type Target = PickDeep<typeof obj, typeof pickOPtions>
/* ===>
type Target = {
primaryEmail: string;
customSchemas: {
Roster: {
prop1: string;
prop2: string;
prop3: string;
};
};
some: {
deeply: {
nested: {
value: number;
};
};
};
phones?: {
value?: string | undefined;
}[] | undefined;
}
*/
También agrego la propiedad phones al objeto pickOptions para demostrar $optional y la selección dentro de arrays. Entiendo que es posible que ya tengas una función pick que use la notación de puntos. En ese caso, puedes crear ya sea una función auxiliar para transformar el objeto pickOptions en una lista de claves con notación de puntos, o una nueva función pick
