Next arguments of function definition depending on first argument - javascript

Consider a list of simple functions with different arguments:
const fns = {
isValidDate: (input: string, min?: Date, max?: Date): boolean => {
// ...
return true;
},
isValidOption: (input: string, options: string[]): boolean => {
// ...
return true;
},
};
They all return the same type (bool);
Then another function that is supposed to call any of the functions above:
function validateField(where: string, fn: keyof typeof fns, ...args: any[]){
// ...
return fns[fn](...args);
}
How can I make args reflect the parameters of the chose fn ?
For example:
validateField("test", "isValidDate", new Date()); // should be ok
validateField("test", "isValidDate", 123); // should fail
and have the arguments show in vscode hints, like on normal functions.
I know I need to create overloads for validateField for each fn, but how to do that with a type definitions or something... without having to manually define each overload and write duplicate code with those arguments

You probably want validateField() to be generic in the type of the fn parameter so you can choose the appropriate type for args. We can write some helper utility types to compute this:
type Fns = typeof fns;
type FnArgs = { [K in keyof Fns]:
Fns[K] extends (input: string, ...args: infer A) => boolean ? A : never
};
/* type FnArgs = {
isValidDate: [min?: Date | undefined, max?: Date | undefined];
isValidOption: [options: string[]];
} */
The FnArgs type is a mapped type where each key comes from the type of fns, and each value is the tuple of parameters after the initial string (using conditional type inference to get that list).
Now you can give validateField() this call signature:
declare function validateField<K extends keyof Fns>(
where: string, fn: K, ...args: FnArgs[K]
): boolean;
and it will work when you call it:
validateField("test", "isValidDate", new Date()); // okay
validateField("test", "isValidDate", 123); // error! number is not assignable to date
validateField("test", "isValidOption", ["a"]) // okay
Unfortunately the implementation of validateField() does not type check:
function validateField<K extends keyof Fns>(where: string, fn: K, ...args: FnArgs[K]) {
return fns[fn](where, ...args); // error!
// -----------------> ~~~~~~~
// A spread argument must either have a tuple type or
// be passed to a rest parameter.
}
The underlying issue is lack of direct support for correlated unions as requested in microsoft/TypeScript#30581. The compiler is not able to understand that the type of fns[fn] is of a function type that is correlated with the type of args. The error message is a bit cryptic, but it comes from the fact that it sees args as a union of tuple types inappropriate for the arguments of fns[fn], which it sees as a union of function types without a common rest parameter type.
Luckily there's a recommended solution for this described in microsoft/TypeScript#47109. We need to give fns a new type that the compiler can see at a glance is an object with methods whose parameters are directly related to FnArgs. Here's how it looks:
function validateField<K extends keyof Fns>(where: string, fn: K, ...args: FnArgs[K]) {
const _fns: { [K in keyof Fns]: (str: string, ...args: FnArgs[K]) => boolean } = fns;
return _fns[fn](where, ...args); // okay
}
The _fns variable is annotated as being of a mapped type of methods explicitly with a rest parameter of tpe FnArgs[K] for every K in the keys of Fns. The assignment of fns to that variable succeeds, because it's the same type.
But the crucial difference is that _fns[fn](where, ...args) succeeds where fns[fn](where, ...args) fails. And that's because the compiler has kept track of the correlation across the generic K between the type of _fns[fn] and the type of args.
And now you have something that works as desired for both the callers and the implementation of the function!
Playground link to code

To solve your question, you can use the Generic type to get the function type and then use this to get the type of the parameter
function validateField<key extends keyof typeof fns>(where: string, fn: key, ...options: Parameters<typeof fns[key]>): boolean {
const fnToCall = fns[fn];
return fnToCall(...options);
}

Related

Typescript: Mapping Tuples to Union types doesn't seem work inside nested objects

I have a Vue project in Typescript, and I'm running into an issue regarding the mapping of an Object of Tuples to an Object of Union types.
For a bit of context, I'm working on a Backend endpoint's expected response types. Currently, this endpoint receives 2 values: an enum, and a string. Depending on the enum the response object will change.
This is the current implementation:
const validations = {
email: ['isValid', 'isAvaliable'],
password: ['isMinLengthValid', 'hasUppercase', 'hasLowercase', 'hasSpecialChars', 'hasNumbers'],
iban: ['isValid', 'isRegistered']
} as const
type ValidationsMap = {
[T in keyof typeof validations]: typeof validations[T][number]
}
function validate<T extends keyof ValidationsMap>(type: T, value: string): Record<ValidationsMap[T], boolean> {
// Do something
}
Now the Backend endpoint will receive one more parameter, and the response object will depend on it too.
This is what I've tried, but it's not working:
const validations = {
portal: {
email: ['isValid', 'isAvaliable'],
password: ['isMinLengthValid', 'hasUppercase', 'hasLowercase', 'hasSpecialChars', 'hasNumbers'],
},
payment: {
email: ['isValid'],
iban: ['isValid', 'isRegistered']
}
} as const
type ValidationsMap = {
[S in keyof typeof validations]: {
[T in keyof typeof validations[S]]: typeof validations[S][T][number] // Error: Type 'number' cannot be used to index type...
}
}
function validate<S extends keyof ValidationsMap, T extends keyof ValidationsMap[S]>(service: S, type: T, value: string): Record<ValidationsMap[S][T], boolean> {
// Do something
}
Does anyone know why this doesn’t work?
I thought there might be a limit to how deep it will allow to map a Tuple to a Union, but it doesn't seem to be the case.
This works correctly:
type PortalEmailValidation = typeof validations['portal']['email'][number]
This seems to be a bug in TypeScript, as described in microsoft/TypeScript#27709. The type checker apparently doesn't properly track the constraints for deep index access types when the keys are generic. That issue has been open for a long time with no sign of progress, so for now all we can do is work around it.
One approach when the compiler won't accept an index access of the form T[K] is to use conditional type inference, like T extends Record<K, infer V> ? V : never (using the Record<K, V> utility type). If K is a (non-optional) key of T, then T will be seen as a Record<K, V> for some V, which we infer.
So instead of typeof validations[S][T][number], we can write typeof validations[S][T] extends Record<number, infer V> ? V : never. Or equivalently:
type ValidationsMap = {
[S in keyof typeof validations]: {
[T in keyof typeof validations[S]]:
typeof validations[S][T] extends { [k: number]: infer V } ? V : never
}
}
Another approach is to explicitly add back in the missing constraint. If you have a type A that you know is assignable to another type B but the compiler doesn't know this, then you can replace A with Extract<A, B> (using the Extract<T, U> utility type) and the compiler will accept it. It knows Extract<A, B> is assignable to B. And assuming you're right about A being assignable to B, then Extract<A, B> will evaluate to just A.
So if ValidationsMap[S][T] is assignable to string but the compiler can't see it, we can write Extract<ValidationsMap[S][T], string> instead:
function validate<S extends keyof ValidationsMap, T extends keyof ValidationsMap[S]>(
service: S, type: T, value: string
): Record<Extract<ValidationsMap[S][T], string>, boolean> { // okay
return null!
}
Playground link to code

Types with different parameters like function overload

I'm trying to write a type which acts as a function signature that allows to have to different set of parameters:
The first case, the function expects a predicate.
The second case, the function expects two parameters.
This is the example: Link to TS PlaGround. I also copy the code here just in case:
type T1<Tentity> = (predicate: ((entity: Tentity) => Tentity | void)) => void;
type T2<TEntity> = (propertyName: keyof TEntity, newValue: unknown) => void;
type TCommon<TEntity> = T1<TEntity> | T2<TEntity>;
interface IEntity {
name: string;
surname: boolean;
}
const entity : IEntity = { name: 'Foo', surname: true };
const method : TCommon<IEntity> = (param1, param2) => {
if (typeof(param1) === 'function') {
param1(entity);
}
else {
(entity[param1] as any)= param2;
}
}
method('name', 'Bar');
method((entity) => { entity.name = 'Bar'});
As you can see, I expect both calls of method to work correctly.
From what I think, TypeScript/IntelliSense should be smart enough to make this reasoning:
Since the first parameter is a function, then we have a T1, therefore I don't even need the second parameter.
Otherwise, the first parameter is a string, thus we have a T2, thus I expect a second parameter as well.
Unfortunately, this is not what's happening: it seems TypeScript cannot "believe" that param1 can also be a function:
I'm pretty sure this can be done because we have the function overload, but I've been able to use them only inside a class. But it's basically the same thing, expect in this case I'm defining the method as a global variable.
Use an interface to describe a set of call signatures:
interface TCommon<TEntity> {
(predicate: ((entity: TEntity) => TEntity | void)): void
(propertyName: keyof TEntity, newValue: unknown): void;
}
But we aren't quite done yet. The second parameter is optional in the sense that the first overload doesn't need a second parameter but the second overload does.
So we mark the second parameter as optional:
const method: TCommon<IEntity> = (param1, param2?: unknown) => {
And that's it.
Playground

Why can't Typescript infer function argument types for optional arguments?

I think it's clearer to try it on TS Playground:
function identity<T extends (...args: any[]) => any>(fn: T): T {
return fn;
}
function fn(args: {
cb: (foo: number) => void,
}) {}
fn({
cb: identity((foo /* infers number */) => {}),
});
function fn2(args: {
cb?: (foo: number) => void,
}) {}
fn2({
cb: identity((foo /* doesn't infer number */) => {}),
});
function fn3(args: {
cb: (foo: number) => void,
} | {}) {}
fn3({
cb: identity((foo /* infers number */) => {}),
});
For fn and fn3, TS was able to infer that foo is a number. However, for fn2, TS just typed foo as any. The typing for fn2 and fn3 are functionally the same thing, so I'm wondering why TS couldn't infer the type of foo.
The realistic use-case for this is React's useCallback, I was trying to infer the argument types for functions that pass through useCallback.
Why does TS behave like this? Is there a less hacky solution?
I think the crucial step here is the inference of identitys return type:
let result: ((foo: number) => void | undefined) = identity(...);
As the return type has a base constraint of (...args: any[]) => any this rule of the Typescript specification applies:
The inferred type argument for each type parameter is the union type of the set of inferences made for that type parameter. However, if the union type does not satisfy the constraint of the type parameter, the inferred type argument is instead the constraint.
~ TypeScript Language Specification (outdated), Contextual Signature Instantiation
As undefined (the optional value) does not satisfy the constraint, the constraint is taken, and T gets inferred as (...args: any[]) => any.
By removing the constraint, T gets inferred correctly
The problem is that in fn2 the cb arg is optional, so identity can't infer from that. So you want to explicit tell TS that that arg can be undefined like so:
function fn2(args: {
cb: (foo: number) => void | undefined
}) {}
This should do the trick and make the infer work again.
TS Playground link

TypeScript: Why is Parameters generic accepting incorrect arguments?

I have a function with defined types for arguments. The arguments are key, which is a key of a specified interface IBook, and value which should have the type corresponding to that specific key in the interface. I.e. if key === 'id' then the only accepted type for value should be number.
The problem arises when I want to create another function to just pass arguments from onChange event to the first one. To avoid re-declaring function arguments again, I used Parameters generic, but it seems to behave incorrectly. Check the usage below.
interface IBook {
id: number;
title: string;
isPromoted?: boolean;
}
export const editBook = <K extends keyof Required<IBook>>(
key: K,
value: Required<IBook>[K],
) => ({
type: 'EDIT_BOOK',
payload: { key, value },
});
const onChange = (...args: Parameters<typeof editBook>) => {
dispatch(editBook(...args));
};
editBook('id', 'some string'); // string not accepted here, shows error
onChange('id', 'some string'); // no error here
editBook('id', true); // boolean not accepted here, shows error
onChange('id', true); // no error here
If I use the original function editBook, then value is typed correctly - it's only the one corresponding to the type of key. If I use another one, an error is shown. However, if I use the wrapper function onChange, then any type existing in IBook is accepted for value argument.
Any way I can fix this?
When you use Parameters you don't capture the type parameters of the original function, typescript will just use the constraint where it finds any reference to the type parameter so the signature of onChange will actually just be:
(key: keyof IBook, value: Required<IBook>[keyof IBook]) => void
which will work out to:
(key: "id" | "title" | "isPromoted", value: string | number | boolean) => void
allowing the invalid calls.
There is unfortunately no explicit way to capture type parameters and forward them to the new function. Starting in 3.4, there is an implicit way to do this, using a pipe function as described here
function pipe<A extends any[], B, C>(ab: (...args: A) => B, bc: (b: B) => C): (...args: A) => C {
return (...args: A) => bc(ab(...args));
}
function dispatch<T extends { type: string }>(action: T): void {
}
const onChange = pipe(editBook, dispatch); // generic type parameter preserved
editBook('id', 'some string'); // string not accepted here, shows error
onChange('id', 'some string'); // error now
editBook('id', true); // boolean not accepted here, shows error
onChange('id', true); // error now

Detecting method signature in Typescript?

Suppose we have something like this:
class TodoStore {
query(state:State):Array<Observable<any>> {
return state.getAll(); //Custom implementation
}
}
At runtime I'd like to find all the methods that have a State argument as the first argument, so that these can be wrapped by a function that passes in the state argument. Is this possible with Typescript?
The wrapper class would work approximately like this:
class TodoStoreWrapper {
constructor(private state:State);
todoStore: TodoStore = new TodoStore();
query() {
todoStore.query(state);
}
}
While at runtime we don't have the type information to find out which functions have a state parameter, we can at compile time create a type that by its structure requires us to specify in an object literal all methods that have a first argument of type state. While this approach does require us to specify the method it is safe, as the compiler will throw an error if we forget a method and will also throw an error if we specify a name that is not such a method.
We can also combine this with a type that removes the state parameter from all methods that have it :
class TodoStore {
query(state: State): Array<Observable<any>> {
return state.getAll(); //Custom implementation
}
queryWithParam(state: State, p:string): Array<Observable<any>> {
return state.getAll(); //Custom implementation
}
}
type StateMethods<T> = Record<{
[P in keyof T]: T[P] extends ((s:State, ...a:any[])=> any) ? P : never
}[keyof T], true>
type Wrapped<T> = {
[P in keyof T]:
T[P] extends ((s: State, ...a: infer A) => infer R) ?
(...a: A) => R :
T[P]
}
function createWrapper<T>(toWrap:T, methods: StateMethods<T>): Wrapped<T>{
return null as any;
}
let w = createWrapper(new TodoStore(), { // This object will be checked by the compiler to contain the keys of all relevant methods
query: true,
queryWithParam: true
});
w.query()
w.queryWithParam("")
A decorator approach is also possible and you should see which fits better for your use case, but this approach can work as well.
Playground link

Categories

Resources