'Accepting different function return types for dynamic property names in TypeScript
Foreword
First and foremost, I want to mention that I have already referenced seemingly related questions here on Stack Overflow, such as this one Stack Overflow #52204566, but none of them appear to actually resolve the issue I am currently experiencing, so I am opening this new question.
My Code
Here is my TSX source on the TypeScriptLang.org Playground, where you can see the error on the Errors tab on the right side of the page.
I am also attaching my TSX code here (below) for reference, but you will not be able to see the TS errors here on Stack Overflow.
import React from 'react'
// Types
interface PersonType { name: string, nameLower: string, species: string[], films: string[], homeworld: string, starships: string[], previous: string | null, next: string | null };
interface SpeciesType { url: string, name: string };
interface FilmType { url: string, director: string, release_date: string, title: string };
interface StarshipType { url: string, name: string, model: string, cost_in_credits: string };
interface PlanetType { url: string, name: string };
interface PersonProps {
person: PersonType,
allSpecies: SpeciesType[],
allFilms: FilmType[],
allStarships: StarshipType[],
allPlanets: PlanetType[]
}
function filterByList(select: string | string[], all: {url: string, [key: string]: any}[], matchKey: string, desiredKey: string): string[];
function filterByList(select: string | string[], all: {url: string, [key: string]: any}[], matchKey: string, desiredKey: string[]): { [desiredKey: string]: string }[];
function filterByList(select: string | string[], all: {url: string, [key: string]: any}[], matchKey: string, desiredKey: string | string[]) {
return (
[select].flat().map((mK: string) => {
const matched = all.find(item => item[matchKey] === mK);
if (matched) {
if (Array.isArray(desiredKey)) {
return desiredKey.reduce((obj: Record<string, any>, key: string) => {
obj[key] = matched[key] ?? null;
return obj;
}, {});
}
return matched[desiredKey] ?? null;
}
}) ?? []
)
};
const generateLabeledList = (label: string, className: string, list: string[]): false | JSX.Element => (
list.length > 0 &&
<div className={`person--${className}`}>
<strong>{label}: </strong>
{
list.length > 1
? <ul className={`person--${className}--items person--${className}--items__list`}>
{list.map((item: string) => <li key={item}>{item}</li>)}
</ul>
: <span className={`person--${className}--items person--${className}--items__single`}>{list[0]}</span>
}
</div>
);
export default function Person({ person, allSpecies, allFilms, allStarships, allPlanets }: PersonProps) {
const searchTerms = ['Luke', 'Skywalker'],
{ name, species: speciesURLs, films: filmURLs, starships: starshipURLs, homeworld: homeworldURL } :
{ name: string, species: string[], films: string[], starships: string[], homeworld: string } = person,
films: string[] = filterByList(filmURLs, allFilms, 'url', 'title'),
species: string[] = filterByList(speciesURLs, allSpecies, 'url', 'name'),
starships: { name: string, model: string }[] = filterByList(starshipURLs, allStarships, 'url', ['name', 'model']),
homeworld: string[] = filterByList(homeworldURL, allPlanets, 'url', 'model');
let highlightedName = name;
if (searchTerms.length) {
const regexSearch = new RegExp(searchTerms.map(term => `(${term})`).join('|'), 'gi');
highlightedName = highlightedName
.replaceAll(regexSearch, '<mark>$&</mark>')
.replaceAll(/(<\/mark>)<mark>/g, '$1<mark class="subsequent">')
.replaceAll(/(<\/mark>\s)<mark>/g, '$1<mark class="subsequent space">');
}
console.log({starships});
return (
<div className="person">
<div className="person--name" dangerouslySetInnerHTML={{__html: highlightedName}} />
{generateLabeledList('Species', 'species', species)}
{generateLabeledList('Homeworld', 'homeworld', homeworld)}
{generateLabeledList('Appears in', 'films', films)}
{generateLabeledList('Piloted', 'starships', starships
.map(({ name, model } : { name: string, model: string }) => name === model ? name : `"${name}" ${model}`))
}
</div>
)
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
The Issue & My Approach
I am building a function in TypeScript which—for parameter—accepts any arbitrary property name, or array of property names. If a single property name (string) is provided, the function returns an array of the values of that property for the objects being evaluated.
If an array of property names is provided, the function returns an array of keyed objects, where each object in the array contains the strings passed to the function as an array as its keys and the values pertaining to each for the objects being evaluated.
I am using function overloading to handle my different return types which resolved many of the errors I was experiencing initially. This final error appears to relate to the return type when I pass an array of property names (which will be used as the keys in the objects returned as an array from the function).
The Error
The error I'm getting is this:
Type '{ [desiredKey: string]: string; }[]' is not assignable to type '{ name: string; model: string; }[]'.
Type '{ [desiredKey: string]: string; }' is missing the following properties from type '{ name: string; model: string; }': name, model
The main lines to pay mind to, regarding the error, are:
- Line 17: Creating the function overload for this return type, where
desiredKeyis an array of stringsstring[]and the return type is currently set to{ [desiredKey: string]: string }[](tried making use of computed property names)
function filterByList(select: string | string[], all: {url: string, [key: string]: any}[], matchKey: string, desiredKey: string[]): { [desiredKey: string]: string }[];
- Line 55: Invoking that function with the argument for parameter
desiredKeyas an array of stringsstring[], per the overload format I created for this case
starships: { name: string, model: string }[] = filterByList(starshipURLs, allStarships, 'url', ['name', 'model']),
- Lines 72-74: Iterating/mapping over the returned array of objects, destructuring the
nameandmodelproperties from each object as I iterate over the array
{generateLabeledList('Piloted', 'starships', starships
.map(({ name, model } : { name: string, model: string }) => name === model ? name : `"${name}" ${model}`))
}
I've been troubleshooting this for several hours now with no "good" solution working as it should for this, without having to break the function into several sub-functions, and even then, this issue of dynamic keys usually results in an error. As this function should be able to be used for any object, I cannot explicitly list the keys as optional properties.
I am looking for a way, preferably using my current approach or one similar, to resolve this error so the function can take in an array of strings and return an array of objects with those strings as its keys as expected.
Any ideas here as to how to get this working would be greatly appreciated. Thanks in advance. 🙏🏼
A few resources I've been referencing while working through this:
- Conditional Types (artsy.github.io): https://artsy.github.io/blog/2018/11/21/conditional-types-in-typescript/
- Conditional Types (typescriptlang.org): https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
- Function Overloading (dmitripavlutin.com): https://dmitripavlutin.com/typescript-function-overloading/
Solution 1:[1]
Ok, took me a while to parse through the code, but basically { [desiredKey: string]: string; } cannot be assigned to { name: string; model: string; } because the second type is narrower. You can use generics in your function signature to define the return type based on the desiredKey values.
Here's a link to the complete code, and here is the filterByList signature.
function filterByList(select: string | string[], all: {url: string, [key: string]: any}[], matchKey: string, desiredKey: string): string[];
function filterByList<DesiredKey extends string>(select: string | string[], all: {url: string, [key: string]: any}[], matchKey: string, desiredKey: DesiredKey[]): { [K in DesiredKey]: string }[];
function filterByList(select: string | string[], all: {url: string, [key: string]: any}[], matchKey: string, desiredKey: string | string[]) {
...
}
You can learn more about generics here.
Solution 2:[2]
I've stripped a lot out of this to try to make the more general part of the answer clearer. If the list of desired fields/keys truly is dynamic, then you won't be able to do this. If your implementation specifies which keys will be fetched, then you're in luck.
The key is using the ArrayOfKeysType[number] construct. In concrete terms, if the array of keys is ['name', 'model'], then the "number" is either 0 or 1 (the array indices), and the resulting type will have the values "name" and "model". Those can in turn be used as the keys for a Record.
getObjectPartial() is untyped for simplicity, so you'll have to ignore the any-errors. Aside from that, you'll see that the only error is when trying to access the url property, which isn't in the set of requested keys. You'll notice that ['model', 'name'] is repeated twice toward the end, but that's to use it once as a type and again as a value/parameter. This, and many other parts of this example should be cleaned up in a production implementation.
type fnReturningSpecifiedFields<
ObjectType,
Fields extends (string | number | symbol)[] = (keyof ObjectType)[],
> = (
objects: ObjectType[] | undefined,
fields: Fields,
) => [
Record<Fields[number], string>,
];
interface StarshipType {
cost_in_credits: string
model: string;
name: string;
url: string;
};
const ships: StarshipType[] = [
{
cost_in_credits: '100',
model: 'F',
name: 'Foo',
url: 'https://star.ship/foo'
},
{
cost_in_credits: '100',
model: 'B',
name: 'Bar',
url: 'https://star.ship/bar'
},
];
const getObjectPartial = (objects, fields) => {
return objects.map((obj) => {
return fields.reduce((partial, field) => {
partial[field] = obj[field];
return partial;
}, {});
});
};
const shipNames = (getObjectPartial as fnReturningSpecifiedFields<StarshipType, ['model', 'name']>)(ships, ['model', 'name']);
const foo = shipNames[0];
foo.model;
foo.name;
foo.url; // invalid because only model and name exist
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | Amiratak88 |
| Solution 2 | Brandon Lee Detty |
