Different object fields in Typescript based on conditional type - reactjs

I want to write a function (eventual use is a React function component) in Typescript that takes a props object with a list of list of objects of any type. Assume the function should print a "key" for each, where if the object type has an id field then the id value is printed as the key, and if the object type doesn't have an id field, the key will be derived from an accessor function in the props object (pseudocode here with types omitted):
function process(props: { items: ..., ...}) {
props.items.forEach(item => {
if (item.id) {
console.log(`Key for ${item.id}`)
} else {
console.log(`Key for ${props.keyFunction(item)}`)
}
})
}
process({items: [{id: "1", name: "A"}, {id: "2", name: "B"}]})
process({items: ["A", "B"], keyFunction: (item) => item})
Ideally, I'd like the following:
Typescript should error if keyFunction is provided but the items already have an id
Typescript should error if the items don't have an id and keyFunction isn't provided
Typescript should know about keyFunction and id in the appropriate places inside the process function body (autocomplete should work)
Is there a way to write the types for this function so that all 3 of the above work?
Note: I understand that if these were parameters instead of values of a config object, I could use conditional function overloads, but because the actual use case for this is a React function component with props, that won't work here.
What I've tried
I've tried using a conditional type, which works at the callsite, but I can't figure out how to make Typescript know about keyFunction correctly playground link:
type KeyProps<T> = T extends { id: string }
? { items: T[] }
: {
items: T[];
keyFunction(item: T): string;
};
function process<T>(props: KeyProps<T>) {
props.items.map(item => {
if (item.id) {
console.log(`Key for ${item.id}`)
} else {
console.log(`Key for ${props.keyFunction(item)}`)
}
})
}
I've also tried using a discriminated union, but I don't know how to provide a type constraint to only one branch of the union with a generic:
type KeyProps<T> =
| { type: "autokey", items: T[] } // How to provide that T should extend { id: string } here?
| { type: "normal", items: T[], keyFunction(item: T): string }

Note that this answer assumes that it isn't important that the two parameters are contained inside of a wrapper object.
Ideally, I'd like the following:
Typescript should error if keyFunction is provided but the items already have an id
Typescript should error if the items don't have an id and keyFunction isn't provided
Typescript should know about keyFunction and id in the appropriate places inside the process function body (autocomplete should work)
Okey, requirement two (2) and three (3) can be solved by simply using function overloads and some cleverly selected default values (you will see that we always call keyFunction and that this is actually a good idea).
However, requirement one (1) is quite tricky. We can easily infer the type of items using a generic T. Then using T we can derive a type D such that D does not contain any object types with a key of id. The tricky part is managing to both derive the base type T from items while also constraining the type of items to D.
The way I've done it is by using an intersection type T[] & D[] for the type of items. This will infer the base type T from elements in the items array, while also constraining the type of the elements in the items array.
interface ObjWithID {
[k: string]: any;
id: string;
}
type AnyWithoutID<T> = T extends { id: any } ? never : T;
function process(items: ObjWithID[]): void;
function process<T, D extends AnyWithoutID<T>>(items: T[] & D[], keyFunction: (value: T) => string): void;
function process(items: any[], keyFunction: (value: any) => string = ({id}) => id) {
items.forEach(item => console.log(`Key for ${keyFunction(item)}`))
}
process([{id: "1", name: "A"}, {id: "2", name: "B"}])
process([{id: "1", name: "A"}, {id: "2", name: "B"}], (item) => item)
process([{ name: "A"}, { name: "B"}], (item) => item.name)
process(["A", "B"], (item) => item)
playground
Note that there is a rather annoying drawback when messing with the type system this much. The type errors end up quite cryptic. For instance process([{id: "1", name: "A"}, {id: "2", name: "B"}], (item) => item) will throw the error Type 'string' is not assignable to type 'never'. for both id & name in both objects. These errors can be really annoying to debug, so make sure you absolutely need this kind of overload behavior before you commit to it fully.

Related

Typescript, create type: items array tuple from interface keys [duplicate]

This question already has answers here:
Enforce that an array is exhaustive over a union type
(2 answers)
Array containing all options of type value in Typescript
(2 answers)
Closed 3 months ago.
It's easier to illustrate with code than to explain with words:
// `type` or `interface` doesn't matter to me.
interface MyCollection {
name: string;
age: number;
}
// The best and simplest solution I came up with.
type List<T> = Array<{ key: keyof T}>;
// I want `MyCollection` _keys_ to be present
// as _values_ for `itemkey` prop in `myList` items.
const myList: List<MyCollection > = [
{ itemkey: 'name' },
{ itemkey: 'age' },
]
It's already not bad, because intellisense works and prevents from using wrong keys. But I need to guarantee that every key present in MyCollection is present in myList, i.e. current solution allows this:
const myList: List<MyCollection > = [
{ itemkey: 'age' },
]
BONUS: would be the possibility to match MyCollection key type as well, e.g.:
type List<T> = Array<{ key: keyof T, value: T[keyof T] }>;
// !BAD `value` accepts either `number` or `string`,
// but should only allow `number`.
const myList: List<MyCollection> = [
{ key: 'age', value: 'should be number, not string' },
]
As far as I know there's no possibility to iterate an interface entries to do this.
What I tried is in the description of the problem. I explored mapped types, tuples, remapping via as and whatever else I could find on the web and in the docs.
Honestly, it's very confusing, a good old JS iteration declaring some kind of static constrains would be way more simpler :D

Define type based on value of keys in an object

I am adding one Select component that has the following structure.
type Option = {
value: string | number
label: string;
icon?: string
}
type SelectProps = {
labelKey?: string;
iconKey?: string;
valueKe?: string;
options: Option[]
}
function Select({
labelKey = 'label',
iconKey = 'icon',
valueKey= 'value',
groupBy = 'groupBy',
options// need to type Option
}: SelectProps) {
// some logic to render options
// options.map(option => (<li key={option[valueKey]}>{option[labelKey]}</li>))
}
Here, options is an array of options and I am trying to give flexibility to users to provide keys to use for labels, icons etc so the user doesn't need to map data all the time.
For now, the Option type has hardcode keys like label, value, icon but I want to create this type based on values passed to labelKey, valueKey, iconKey etc.
For example, if a user passes the labelKey="name" prop then the Option type should allow the following data:
[ {
name: 'Product',
value: 'product',
icon: 'product'
}]
So far I have tried the following implementation but it sets all keys' types to string.
type OptionKeys = {labelKey: string, valueKey: string, iconKey: string}
type Option<T extends OptionKeys> = {
[label in T["labelKey" | "valueKey" | "iconKey"]]: string // all the types are string
}
type SelectProps<T extends OptionKeys = {
labelKey: 'label',
valueKey: 'value',
iconKey: 'icon'
}> = {
labelKey?: string
valueKey?: string;
iconKey?: string;
options: Option<T>[]
}
Here, the Option's keys have value of type string but I want to define type based on key. For example, if the key is labelKey, I want its value to be number | string etc.
One option, I see here is to accept OptionType from outside by making the Select component generic but in that case, I need to refactor my component and want to avoid this refactoring at the moment.
How can I update my type to handle this scenario?
Using the FromEntries type from this blog post: https://dev.to/svehla/typescript-object-fromentries-389c,
Define the SelectProps type as follows:
type SelectProps<LK, VK, IK> = {
labelKey?: LK
valueKey?: VK
iconKey?: IK
options: FromEntries<[
[LK, string],
[VK, string | number],
[IK, string | undefined]
]>
}
We have three keys in the options, the label key (LK) which is a string, the value key (VK) which is a string | number and the icon key (IK) which is a string | undefined
Now we define the Select function as follows:
function Select<
LK extends string = 'label',
VK extends string = 'value',
IK extends string = 'icon'
>(props: SelectProps<LK, VK, IK>) {}
It is important to put the default key names on the function itself rather than the SelectProps type. I am not sure why.
Full playground link

Typescript: How to create Array generic type that includes objects with every instance of keyof given interface

I have interface
interface Item {
location: string;
description: string;
}
And generic Field interface
interface Field<T extends object> {
name: keyof T;
label: string;
}
I would like to have some Array<Every<T extends object>> that will check that array includes at least one instance of every Field<Item> of Item
Example:
Error:
const fields: Every<Field<Item>[]> = [{ name: 'description', label: 'Description' }]; //Error: missing Field of "location" instance
Correct:
const fields: Every<Field<Item>[]> = [
{ name: 'description', label: 'Description' },
{ name: 'location', label: 'location' },
];
Workaround
TypeScript doesn't have built-in functionality that represents exhaustive arrays like this. You could try to write a type which is the union of all possible tuples which meet your criteria, but this will not scale well for moderately sized unions, and might not even be tractable for small cases if you want to allow for duplicate entries.
If I really wanted to do this, I'd be inclined to write a helper function that would try to infer the field names from the array and then use a conditional type that causes a compiler error if those field names do not exhaust keyof T. This is possibly fragile and definitely complicated, but here is one possible implementation:
interface FieldNamed<K extends PropertyKey> {
name: K,
label: string
}
const exhaustiveFieldArray = <T extends object>() => <K extends keyof T>(
...fields: [FieldNamed<K>, ...FieldNamed<K>[]] &
(keyof T extends K ? unknown : FieldNamed<Exclude<keyof T, K>>[])
): Field<T>[] => fields;
const exhaustiveItemFieldArray = exhaustiveFieldArray<Item>();
The function exhaustiveFieldArray<T>() takes a manually-specified type parameter T and returns a new function which accepts a variadic number of arguments of type Field<T>, and complains if it can't be sure that you included all field names.
Let's make sure that it works before we try to explain how it works:
const fields = exhaustiveItemFieldArray(
{ name: 'description', label: 'Description' },
{ name: 'location', label: 'location' }
); // okay
const badFields = exhaustiveItemFieldArray(
{ name: 'description', label: 'Description' },
{ name: 'locution', label: 'location' } // error!
//~~~~ <-- Type '"locution"' is not assignable to type 'keyof Item'
)
const badFields2 = exhaustiveItemFieldArray(
{ name: 'description', label: 'Description' } // error!
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Type '"description"' is not assignable to type '"location"'.
)
const badFields3 = exhaustiveItemFieldArray(); // error!
// expected at least one argument
const badFields4 = exhaustiveItemFieldArray(
{ name: 'location', label: 'location' },
{ name: 'location', label: 'location' }
) // error!
This all looks okay to me. If we are missing fields, we get errors.
Here's a sketch of how it works. The returned function is generic in the type parameter K extends keyof T. The fields rest parameter is the intersection of two types. The first one is used to infer what was passed in:
[FieldNamed<K>, ...FieldNamed<K>[]]
That means it is an array of at least one element (it's a tuple of one element followed by some number of other elements), each of which must be of type FieldNamed<K> for the inferred K. This will end up making K the union of all field names included.
The second type is used to check that K is exhaustive of keyof T. We already know K extends keyof T, but we want to make sure that keyof T extends K also:
& (keyof T extends K ? unknown : FieldNamed<Exclude<keyof T, K>>[])
This conditional type checks keyof T extends K. If it is true, then everything is fine, and we return unknown. Intersecting with unknown is a no-op (XYZ & unknown is equivalent to XYZ), so that doesn't prevent anything from compilng. If it is false, then we have a problem, and we return FieldNamed<Exclude<keyof T, K>>[]. The Exclude<T, U> utility type removes elements from a union; so Exclude<keyof T, K> gives us those keys we left out. And so we are intersecting the actual array type with an array of fields which we missed. This will result in compiler errors complaining about the fact that you missed things. The errors might not be the most comprehensible, but at least there are errors.
So, hooray? It works as far as it goes, but I don't know if it's worth it to you. You might instead consider changing your data structure from an array (which is hard for the compiler to check) to an object whose keys are the same as T (which is easy for the compiler to check). But this answer is already long so I'm not going to expand the scope further to show how such a thing would be implemented. 😅
Playground link to code

Declare type with single required property and unbounded additional properties

I want to declare a function that takes an argument which has a type defined as an object with a single required property and any number of additional properties open format (T) while requiring the additional properties to adhere to the type signature of T. Specifically I'm trying to do something like this:
export myFunc<T>(props: {
data: {
key: string;
[x: T]: any;
}[]
}) { // myFunc code... }
The above definitely doesn't work. I've tried the approach using [x: string]: any; but that is too permissive and allows deviation from the type signature for T.
TypeScript doesn't currently have great support for the type you're talking about. The problem is that if an index signature exists, all named properties corresponding to the index signature must be compatible with it. Assuming that string is not assignable to T, the type {key: string, [k: string]: T} won't work. This restriction kind of makes sense, but it has been a source of frustration sometimes.
Maybe in the foreseeable future it will be possible to use arbitrary index signature types and negated types; if so, you could likely express the type as something like {key: string; [K: string & not "key"]: T}. That is, you will be able to explicitly exclude "key" from the index signature. But we're not there yet.
One thing you can do is use an intersection like {key: string} & {[k: string]: T} to circumvent the issue. But this doesn't work in some cases, especially yours, where you are likely to pass in an object literal:
declare function myFunc<T>(props: {
data: Array<{
key: string
} & { [x: string]: T }>
}): void;
myFunc<number>({ data: [{ key: "a" }] }); // error!
// ~~~~~~~~~~~~ <-- key is incompatible with index signature
The other workaround is to make the myFunc a generic function that infers the type of the props parameter as generic type P, and then uses a conditional type to verify that P meets your requirement. It's very long and messy and actually requires me to make it a curried function to allow you to manually specify T but then have the compiler infer P (the consequence of another currently missing feature in TypeScript):
type EnsureProps<
P extends { data: Array<{ key: string }> },
T,
A extends any[] = P['data']
> = {
data: {
[I in keyof A]: {
[K in keyof A[I]]?: K extends 'key' ? string : T
}
}
};
declare const myFuncMaker: <T>() =>
<P extends {
data: Array<{ key: string }>
}>(props: P & EnsureProps<P, T>) =>
void;
But at least it does work:
const myFunc = myFuncMaker<number>();
myFunc({ data: [{ key: "a", dog: 1, cat: "2" }] }); // error!
// ~~~ <-- string is not assignable to never
myFunc({ data: [{ key: "a", dog: 1 }, { key: "b", cat: 4 }] }); // okay
So let's step back. All of that is either unworkable, problematic, or a nightmare of type jugging. The compiler really doesn't want to let you represent this type. I'd probably suggest that you think about refactoring the myFunc() function so that the parameters are more amenable to TypeScript's type system. For example, if you push the additional properties of type T down one level but leave the "key" where it is, it would work well:
declare function myFunc<T>(props: {
data: Array<{
key: string,
more?: { [x: string]: T };
}>
}): void;
myFunc<number>({ data: [{ key: "a", more: { a: 1, b: 2 } }] });
I can see how that's a bit less convenient for you, but it might be worth it to save yourself the headache of having the compiler fighting against you.
Anyway, hope that helps; good luck!

How to use interface to type function argument in Flow

I'm trying to implement a React component that contains a list of options and shows their id and name. I want this component to be reusable so I define an interface Option to ensure the required fields are always provided.
And here comes the issue: if I pass any type with more fields than those 2 { id, name, /* anything */}, Flow complains. Is it not possible to use interfaces in Flow like this?
Here's the minimal relevant code:
interface Option {
id: string,
name: string
}
const List = (options: Option[]) => {
options.forEach(o => null)
}
type ImplementsOption = {
id: string,
name: string,
description: string
}
const plans: ImplementsOption[] = []
List(plans)
Error:
Cannot call List with plans bound to options because property description is missing in Option 1 but exists in ImplementsOption [2] in array element.
Trying with casting:
List((plans: Option[]))
And also with classes:
class ComplexOption implements Option {
id: string
name: string
}
const complexOptions: ComplexOption[] = []
List(complexOptions)
Nothing seems to work!
There is a playground with all these snippets already.
Imagine we had a list of ImplementsOption: [{ id: 'id', name: 'name', description: 'description' }, ...]. Now we pass it into the List function, which has the signature Option[] => void. This is totally valid from the point of view of List since ImplementOption is a supertype of Option. However, there is no guarantee in the List function that it won't modify the list that is passed in. Thus, the function could add an element of type Option to the list, which would be valid for a Option[] but invalid for a ImplementsOption[].
To fix this, you can type plans as a $ReadOnlyArray<Option>, which tells Flow that the List function will not modify the elements (try flow).
Reference issues #4425, #4483, or #5206 for more information.

Resources