Issue
I have a piece of code like this:
import { discard } from "../utilities/utility"
export type Arguments<T> = T[keyof T][]
export function useCommand<TObject extends {}, TValue = void>(
fn: (arg: TObject) => Promise<TValue>,
onError: (e: unknown) => void = (e) => discard(e),
onSuccess: (ret: TValue) => void = (r) => discard(r)
) {
return async (...obj: Arguments<TObject>) => {
const arr: unknown = Object.assign({}, obj)
const requiredArgs = arr as TObject
try {
if (!!arguments) {
const r = await fn(requiredArgs)
onSuccess(r)
}
} catch (err) {
onError(err)
}
}
}
here is how I use it
const func = useCommand(async (arg: {email: string, code: number}) =>{
// some code
})
// now elsewhere
func("JohnDoe", 2345)
// this is wrong, one argument mising
func("JohnDoe")
// but this one is right, string|number
func(1, 2)
is it possible to have func like this:
func(a: string, b: number)
Tried a few things but nothing worked, is it even possible in typescript?
Solution
This is impossible to implement directly as asked.
Imagine you were to call, for example,
const c = useCommand(
async (x: { a: string, b: number }) =>
console.log(x.a + " " + x.b.toFixed(2))
)
c("z", 1);
TypeScript types are erased upon compilation to JavaScript, so the above code will become something like this:
const c = useCommand(
async (x) =>
console.log(x.a + " " + x.b.toFixed(2))
)
c("z", 1);
How would the implementation of useCommand
have any idea what keys to use for the "z"
and 1
arguments? There's no mention of "a"
or "b"
anywhere in that code except for the body of the callback function. And even if you could somehow probe the body of the function in JavaScript, to see that x.a
and x.b
imply that the input type needs to be at least of the form {a: ?, b: ?}
, there's no way to know whether "z"
goes on the a
property or the b
property, or possibly on some other property that happened not to be mentioned in the function.
Your implementation of Object.assign({}, obj)
certainly doesn't do this; all that does is copies an array into an object, so you turn ["z", 1]
into {0: "z", 1: 1}
. That doesn't help... and when you run your example code it ends up behaving like {a: undefined, b: undefined}
.
Finally, object types don't even maintain a usable property order in TypeScript anyway. So even if the type {a: string, b: number}
were not erased, that type is identical in TypeScript to {b: number, a: string}
. It has the same properties, just written in a different order. Indeed, the value {a: "z", b: 1}
and the value {b: 1, a: "z"}
are treated the same by TypeScript, and either one would be assignable to either version of the type. So there'd be no way to understand whether you meant to call c("z", 1)
or c(1, "z")
... which is really unsolvable, especially if you ever have multiple parameters of the same type, like f(0, 1)
vs f(1, 0)
.
Any attempt to tease property order out of an object type will run into all kinds of weird issues, such as those described at How to transform union type to tuple type. It's just not a road you want to go down.
The only way this could possibly work is to pass in an array of key names into useCommand
, so both the TypeScript compiler and the JavaScript runtime knows how to match up these keys with parameters. So the call might look like:
const c = useCommand(
async (x: { a: string, b: number }) =>
console.log(x.a + " " + x.b.toFixed(2)),
["a", "b"]
)
If that's okay with you, then the useCommand()
function could look like this:
function useCommand<O extends
{ [P in K[number] | keyof O]: P extends K[number] ? any : undefined },
const K extends readonly (keyof O)[],
V = void
>(
fn: (arg: O) => Promise<V>,
keys: K,
onError: (e: unknown) => void = (e) => discard(e),
onSuccess: (ret: V) => void = (r) => discard(r)
) {
return async (...args: { [I in keyof K]: O[K[I]] }) => {
const requiredArgs = Object.fromEntries
(keys.map((k, i) => [k, args[i]])) as unknown as O
try {
if (!!arguments) {
const r = await fn(requiredArgs)
onSuccess(r)
}
} catch (err) {
onError(err)
}
}
}
Here we add the generic type K
corresponding to the tuple type of the keys
parameter (where K
has been modified with const
to give the type checker a hint that you care about the exact order and contents of the keys
parameter, so it doesn't get inferred as the useless string[]
type). And we constrain both the object type O
and the type K
so that they match up with each other; if you include a key in K
that isn't present in O
or vice versa you should get an error.
Then the implementation also has to change. The input args
type is the mapped tuple type {[I in keyof K]: O[K[I]]}
meaning that we replace each key from K
with the corresponding element type from O
.
Now instead of Object.assign({}, args)
, we can use Object.fromEntries(keys.map((k, i) => [k, args[i]]))
via the Object.fromEntries()
method. For each key in keys
and argument at the same position i
of args
, we can create an object entry with key keys
and value args[i]
. And now there's no ambiguity or uncertainty; the first argument goes with the first key in keys
, and so on.
Let's try it out:
// const c: (args_0: string, args_1: number) => Promise<void>
c("z", 1); //"z 1.00"
Looks good! The function type has the right argument types in the right order, and the implementation works at runtime as well.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.