How to limit function input to dynamically assigned keys of an object in typescript? - node.js

lately I am trying to create a placeholder object that I will be able to use with typescript.
Idea is to have an empty object and two functions:
one to add new key to a placeholder object with another object as a value ( 'add' function )
and one to get this value by passing a key that already exists in a placeholder ( 'get' function )
I would like typescript to forbid to type keys that already exist in placeholder in 'add' function.
Also I would like to get suggestions while typing key in 'get' function.
Last thing that I would like to achieve is to have type of an object that is returned from 'get' function instead of 'any' or 'object'
Here is the sample code with some basic typing:
let placeholder = {}
function add(key: string, test: object) {
placeholder[ key ] = test
}
function get(key: string ) {
return placeholder[key]
}
add('test1', { val: 1 }) // here 'test1' is ok
add('test1', { val: 2 }) // here 'test1' should rise an error
let t1 = get('') // here 'test1' and should be suggested
t1.val // here t1 should have type { val: number }
So far I have tried using generic types with things like:
function add( key: Omit< string, keyof typeof placeholder >, test: object ) { ... } // it is casting key to properties of string
function get< Key extends keyof typeof placeholder > ( key: Key ) { ... } // it only works with static keys

That is not possible. This would require the type of the object to change, but types are static.
The only thing you could do would be to return an object from your add function with a modified type and then continue calls on that object.
Example of that approach (still has one typing issue i set to ignore):
class Placeholder<T>
{
constructor(private entries: T)
{
}
add<K extends string, V>(key: Exclude<K, keyof T>, value: V): Placeholder<T & { [X in K]: V }> {
const newEntries = { ...this.entries, [key]: value };
// #ts-ignore
return new Placeholder(newEntries);
}
get(key: keyof T) {
return this.entries[key];
}
}
const ph = new Placeholder({})
.add('test1', { val: 1 }) // here 'test1' is ok
.add('test1', { val: 2 }) // here 'test1' should rise an error
let t1 = ph.get('test1') // here 'test1' and should be suggested
t1.val // here t1 should have type { val: number }
[Playground Link]

Related

Recursive function type based on object keys

Im trying to create a recursive function type that takes the outer most keys in an object type as an argument and returns a new function that then takes the next outer most keys of the object from the given key recursively until no more keys are available.
If the key doesn't extend /${string} the argument must be of an object type containing the key ans and property:
type Argument<T> = T extends `/${string}` ? T : { [key in T]: string | number }
There will only be one key that wont extend /${string} per. level.
Example
const values = {
'/aaa': {
'/lorem': {
foo: 'hello world',
'/boo': 12345,
},
},
bbb: {
'/ipsum': {
'/dolor': 'lorem ipsum',
amet: 567890,
},
},
'/ccc': ...
};
foo<typeof values>('/aaa')('/lorem')({ foo: '...' }); // should return type of string
foo<typeof values>('/aaa')('/lorem')('/boo'); // should return type of number
foo<typeof values>('/aaa')('/ipsum'); // should fail
foo<typeof values>({ bbb: '...' })('/ipsum')({ amet: '...' }); // should return type of number
foo<typeof values>({ bbb: '...' })('/ipsum')('/dolor'); // should return type of string
foo<typeof values>({ bbb: '...' })('/lorem'); // should fail
My current code
I have a type that almost does the job., but doesn't work with the non /${string} extensions :(
type Foo<T extends object> = <K extends keyof T>(args: K) => T[K] extends object ? Foo<T[K]> : T[K];
const values = {
'/aaa': {
'/lorem': {
foo: 'hello world',
'/boo': 12345,
},
},
bbb: {
'/ipsum': {
'/dolor': 'lorem ipsum',
amet: 567890,
},
},
};
const foo = {} as Foo<typeof values>
foo('/aaa')('/lorem')('/boo') // works :D
foo('/aaa')('/lorem')({ foo: '...' }) // fails :(
I tried to handle the args type within the function - But it can't return the next keys :(
type Foo<T extends object> = <K extends keyof T>(
args: K extends `/${string}` ? K : { [key in K]: string | number }
) => T[K] extends object ? Foo<T[K]> : T[K];
const values = {
'/aaa': {
'/lorem': {
foo: 'hello world',
'/boo': 12345,
},
},
bbb: {
'/ipsum': {
'/dolor': 'lorem ipsum',
amet: 567890,
},
},
};
const foo = {} as Foo<typeof values>
foo('/aaa')('/lorem')('/boo') // works :D
const a = foo({ bbb: '...' })('/ipsum') // fails :(
I don't even know if want I'm trying is possible - but if you have any suggestions you would save my day :)
Thanks for your time.
Hmm, problem seems to be with {[key in K]: string | number}, apparently typescript does not know if T[K] is an object after that.
If you change it to {[key: string]: K} it works (but {[key: string|number]: K} does not). This seems like an issue with Typescript (I am using version 4.7.4.)
Best I can do is using a second parameter instead of an object:
type Foo<T extends object> = <K extends keyof T>(
arg: K,
val?: K extends `/${string}` ? undefined : string|number
) => T[K] extends object ? Foo<T[K]> : T[K];
const foo = {} as Foo<typeof values>
const boo = foo('/aaa')('/lorem')('/boo') // TS: boo is number
const ipsum = foo('bbb', 12)('/ipsum') // TS: ipsum is Foo<{'/dolor': string, amet: number}>

Typescript: Generic type of method params to match type of callback function params

I'm trying to make a class that accepts a function in the constructor. The function can have arguments of any type. Then I want to put a method on the class that accepts that same arguments as function parameter, as it will be a wrapper around this callback. Here's a simplified example to show what I'm trying to do
interface Options<T> {
callbackFn(...x: any[]) => Promise<T>
}
class ExampleClass<T> {
private options: Options<T>;
result: T;
constructor(options: Options<T>) {
this.options = options;
}
async wrapperFn(...x: any[]) {
// Do some stuff before the callback
this.result = await this.options.callbackFn(x)
// Do some stuff after
}
}
const example = new ExampleClass<string>({
callbackFn: (a: string, b:string) => new Promise((res) => {
res(a + b);
})
});
example.wrapperFn("foo", "bar")
This is basically the way I have it now, and it works but it obviously doesn't enforce the types of the params of wrapperFn which isn't ideal. Is there any way to do something like this?
If you want the compiler to keep track of both the callback return type and the callback argument list type, then you'll want Options to be generic in both the return type (you called it T but I'll call it R for "return") and the argument list type (I'll call it A for "arguments"):
interface Options<A extends any[], R> {
callbackFn(...x: A): Promise<R>
}
Now you can just use A anywhere you were using any[] before, and you'll get stronger typing. This also implies that ExampleClass needs to be generic in A and R too:
class ExampleClass<A extends any[], R> {
private options: Options<A, R>;
result?: R;
constructor(options: Options<A, R>) {
this.options = options;
}
async wrapperFn(...x: A) {
// Do some stuff before the callback
this.result = await this.options.callbackFn(...x)
// Do some stuff after
}
}
Let's test it out:
const example = new ExampleClass({
callbackFn: (a: string, b: string) => new Promise<string>((res) => {
res(a + b);
})
});
// const example: ExampleClass<[a: string, b: string], string>
example.wrapperFn("foo", "bar") // okay
example.wrapperFn("foo", 123); // error!
// --------------------> ~~~
// Argument of type 'number' is not assignable to parameter of type 'string'.
Looks good.
Playground link to code

Generic function to get a nested object value

The problem itself is more complex than this but I'll try to explain in a simple way.
I have an object (it could be a different object, lets say a car o user or whatever).
const car = {
model: 'Model A',
specs: {
motor: {
turbo: true
}
}
};
Then I have a function to get a value from a property name.
interface Data<T, K extends keyof T> {
keynames: string[];
values: Array<T[K] | null>;
}
const getValues = <T, K extends keyof T>(keynames: string[], data: T): Data<T, K> => {
const values: Array<T[K] | null> = [];
keynames.forEach(keyname => {
const value: T[K] | undefined = getObjectValue(data, keyname);
if (typeof value === 'undefined') {
values.push(null);
} else {
values.push(value);
}
});
return {
keynames,
values
}
};
// This works with key.name syntax, specs.motor.turbo returns true for example
const getObjectValue = <T, K extends keyof T>(
object: T,
keyName: string
): T[K] | undefined => {
const keys = keyName.split('.');
if (keys.length === 1) {
if (typeof object === 'object') {
if (keys[0] in (object as T)) {
return (object as T)[keys[0] as K];
}
return undefined;
}
return undefined;
} else {
const [parentKey, ...restElements] = keys;
if (!object) return undefined;
return (getObjectValue(
(object as T)[parentKey as K],
restElements.join('.')
) as unknown) as T[K];
}
};
The problem becomes when I write some tests for example:
// ...
const data = getValues(['model', 'specs.motor.turbo'], car);
assert.deepEqual(data, {
keynames: ['model', 'specs.motor.turbo'],
values: ['Model A', true],
});
// ...
data is what I'm expecting, my function returns the same object as expected but I'm getting an error:
Type 'boolean' is not assignable to type 'string | { motor: { turbo: boolean; }; }'.ts(2322)
which is obvious since I'm only inferring T[K] in my function and not the type of the nested properties.
How to achieve that, so my function works with return types string | { motor: { turbo: boolean; }; } | { turbo: boolean; } | boolean. Or maybe there is a simpler way to return a nested property value.
I'm going to preserve your implementation as much as possible, even though there might be some improvements there that someone would suggest. I'm primarily taking this question as "how can I tell the TypeScript compiler what getValues() is doing?".
First, we need to represent what comes out when you deeply index into a type T with a dotted keypath K. This is only going to be possible in TypeScript 4.1 and later, since the following implementation relies both on template literal types as implemented in microsoft/TypeScript#40336, and recursive conditional types as implemented in microsoft/TypeScript#40002:
type DeepIndex<T, K extends string> = T extends object ? (
string extends K ? never :
K extends keyof T ? T[K] :
K extends `${infer F}.${infer R}` ? (F extends keyof T ?
DeepIndex<T[F], R> : never
) : never
) : never
The general plan here is to check if K is a key of T; if so, we do the lookup with T[K]. Otherwise we split K at the first dot into F and R, and recurse downward, indexing into T[F] with the key R. If at any point something goes wrong (F is not a valid key of T, for example), this returns never instead of property type. We will use this; if something becomes never then we assume that there was a bad index:
type ValidatePath<T, K> =
K extends string ? DeepIndex<T, K> extends never ? never : K : never;
The type ValidatePath<T, K> will extract from the (possible union of keys) K just those members which are valid paths.
Before we get into it, let's also represent what you do when you replace undefined with null:
type UndefinedToNull<T> = T extends undefined ? null : T;
For your code, I'm going to go ahead and use tuple types to represent the keynames and values arrays in Data. This should fall back to unordered arrays if necessary, but it seems weird to throw away information you know; for example, on your car example, you know that the first element of the values array is a string and the second is a boolean. A type like [string, boolean] has more information than Array<string | boolean | null>:
interface Data<T, K extends string[]> {
keynames: K;
values: { [I in keyof K]: UndefinedToNull<DeepIndex<T, Extract<K[I], string>>> };
}
The values property uses a mapped array/tuple to convert the tuple of keynames in K to a tuple of deep indexed values.
Now we need to give strong types to your function signatures. The compiler won't be able to verify a lot of the type safety inside of the implementations, so I'll be doing a lot of type assertions with as to force the compiler to accept what we're doing:
const getValues = <T, K extends string[]>(keynames: (K & { [I in keyof K]: ValidatePath<T, K[I]> }) | [], data: T): Data<T, K> => {
const values = [] as { [I in keyof K]: UndefinedToNull<DeepIndex<T, Extract<K[I], string>>> };
const _keynames = keynames as K;
_keynames.forEach(<I extends number>(keyname: K[I]) => {
const value: DeepIndex<T, Extract<K[I], string>> | undefined = getObjectValue(data, keyname as any);
if (typeof value === 'undefined') {
values.push(null as UndefinedToNull<DeepIndex<T, K[I]>>);
} else {
values.push(value as UndefinedToNull<DeepIndex<T, K[I]>>);
}
});
return {
keynames: _keynames,
values
}
};
const getObjectValue = <T, K extends string>(
object: T,
keyName: K & ValidatePath<T, K>
): DeepIndex<T, K> | undefined => {
const keys = keyName.split('.');
if (keys.length === 1) {
if (typeof object === 'object') {
if (keys[0] in (object as T)) {
return (object as T)[keys[0] as keyof T] as DeepIndex<T, K>;
}
return undefined;
}
return undefined;
} else {
const [parentKey, ...restElements] = keys;
if (!object) return undefined;
return (getObjectValue(
(object as T)[parentKey as keyof T],
restElements.join('.') as any as never
) as unknown) as DeepIndex<T, K>;
}
};
I wouldn't worry too much about the assertions inside the implementation. The important piece here is the call signature to getValues():
<T, K extends string[]>(keynames: (K & { [I in keyof K]: ValidatePath<T, K[I]> }) | [], data: T) => Data<T, K>
We are interpreting the keynames as something assignable to an array of string values. The | [] at the end is just a hint that the compiler should prefer viewing, say, ['model', 'specs.motor.turbo'] as an ordered pair instead of an unordered array. The intersection with {[I in keyof K]: ValidatePath<T, K[I]>}, a mapped tuple, should be a no-op is all the elements of K are valid paths into T. If any element of K is an invalid path, though, that element of the mapped tuple will be never and the validation will fail.
Let's test it:
const car = {
model: 'Model A',
specs: {
motor: {
turbo: true
}
}
}
const data = getValues(['model', 'specs.motor.turbo'], car);
data.keynames; // ["model", "specs.motor.turbo"]
data.values; // [string, boolean]
console.log(data);
This looks like exactly what you want with your example. What happens if we misspell a key?
getValues(['model', 'specs.motar.turbo'], car); // error!
// ---------------> ~~~~~~~~~~~~~~~~~~~
// Type 'string' is not assignable to type 'undefined' 🤷‍♂️
We get an error on the offending key. The error message isn't that useful, unfortunately. When I tried to use the answer to this question to give the compiler a full list of exactly which dotted paths to accept, it caused a massive slowdown and even some "type instantiation is excessively deep or infinite" errors. So while it would be nice to see "specs.motar.turbo" is not assignable to "model" | "specs" | "specs.motor" | "specs.motor.turbo"`, sadly I can't get that to happen in a reasonable way.
What if there could be an undefined value at the property in question?
const d2 = getValues(['a.b'], { a: Math.random() < 0.5 ? {} : { b: "hello" } });
d2.keynames; // ["a.b"]
d2.values; // [string | null]
console.log(d2);
It comes out as | null instead of | undefined, which is good.
So that works as well as I could get it to. There are undoubtedly limitations and edge cases. Deep indexing with dotted keys is kind of near the edge of what works in TypeScript (before 4.1 is was significantly past the edge, so that's something, right?). For example, I'd expect weird/bad things to happen with optional or union-typed properties at non-leaf nodes of the object tree. Or objects with string index signatures, for that matter. These might be addressable, but it would take some effort and a lot of testing. The point is: tread carefully.
Playground link to code

Extract "defined" type from property in TypeScript at runtime

What I want to do
I'm currently looping over an object's keys and transferring the values to another object.
interface From {
[key: string]: string;
}
let from: From = {
prop1: "foo",
prop2: "23",
};
interface To {
[key: string]: string | number | boolean | undefined;
prop1: string;
prop2: number;
prop3: boolean;
}
let to: To = {} as To;
const initEnv = async () => {
const keys: string[] = Object.keys(from);
for (let i = 0; i < keys.length; i++) {
let key: string = keys[i];
let keyType = typeof (key as keyof To); // This only returns "string". Which kind of makes sense to me
let keyType = typeof keyof key; // SyntaxError: ',' expected
let keyType: typeof to[key]; // Error: 'key' refers to a value, but is being used as a type
to[key] = from[key];
}
};
I would want to be able to, say switch the value, so I don't want to just extract the type of the key. I want to assign it to a variable for use in the code, thus at runtime, as a string for instance.
So I think things like this wouldn't work.
let keyType2: typeof env[key]; // Error: 'key' refers to a value, but is being used as a type
Maybe, but the question is then; what do I assign to this variable?
The reason for all this, is that I want to convert the from variables to the correct type before, assigning them to the to object.
So yeah, basically, my question is how I would extract the type (as a string, at runtime, dynamically) from the key. Or is it even possible in the first place? And if it isn't why not? I like understanding things.
Thanks for putting up with my bad english, as well.
There are no interfaces at runtime; TypeScript's type system is erased from the emitted JavaScript. Your from and to values will be evaluated like this at runtime:
let from = {
prop1: "foo",
prop2: "23",
};
let to = {};
There's no From or To, and no way to use To to figure out how to coerce from's properties into the right types. The type system has no runtime effects.
The usefulness of TypeScript's type system comes from describing what will happen at runtime and not from affecting things at runtime. Imagine how you would have to write your code in pure JavaScript, and then give types to that code. Here's one way I might do it. Instead of a To interface, let's make a To object whose properties are functions that coerce inputs to other types:
const To = {
prop1: String,
prop2: Number,
prop3: Boolean
}
This is enough information to proceed at runtime.
Now, if you were going to build to manually, the compiler would be able to understand that the resulting value has a prop1 property of type string and a prop2 property of type number:
const toManual = { prop1: To.prop1(from.prop1), prop2: To.prop2(from.prop2) };
/* const toManual: { prop1: string; prop2: number; } */
But you don't want to do it manually; you'd like to write a loop that walks through the keys of from and uses To to produce properties of to. This is harder for the compiler to understand, but with judicious use of type assertions and type annotations you can write an objMap function that works programmatically:
function objMap<T, F extends { [K in keyof T]: (arg: T[K]) => any }>(
obj: T, fMap: F) {
const ret = {} as { [K in keyof T]: ReturnType<F[K]> };
const fM: { [K in keyof T]: (arg: T[K]) => ReturnType<F[K]> } = fMap;
(Object.keys(obj) as Array<keyof T>).forEach(<K extends keyof T>(k: K) => {
ret[k] = fM[k](obj[k]);
})
return ret;
}
The objMap function takes an object obj and a mapping object fMap which has at least all the same keys as obj and whose properties are functions that map obj's properties. The return type of objMap is an object whose properties are all the returned values of the fMap function for each property in obj. The actual work is being done by ret[k] = fM[k](obj[k]);. It's the programmatic equivalent of ret.prop1 = To.prop1(from.prop1); and ret.prop2 = To.prop2(from.prop2).
Let's see if it works:
const to = objMap(from, To);
/* const to: { prop1: string; prop2: number; } */
console.log(JSON.stringify(to));
// {"prop1":"foo","prop2":23}
That looks correct; the type of to is inferred by the compiler to be {prop1: string, prop2: number}, and the actual value of to is computed at runtime to be {prop1: "foo", prop2: 23}.
Okay, hope that helps; good luck!
Playground link to code

How can I use typescript generics for dynamic function arguments

I am trying to create a wrapper method around node's gRPC bindings. I'd like to make a method called rpc on WrapperClient that invokes a method on the underlying GrpcClient class but also type check both the method and the request arguments.
Here is an example I'll cross post to the TS playground.
type ReqA = { type: 'a' }
type ReqB = { type: 'b' }
class GrpcClient {
findA(request: ReqA) { };
findB(request: ReqB) { };
}
class WrapperClient {
rpc<GrpcClient, TMethod extends keyof GrpcClient>(client: GrpcClient, method: TMethod, req: any) {
}
}
const grpcClient = new GrpcClient()
const client = new WrapperClient()
// This works
grpcClient.findA({ type: 'a' }) // correct
grpcClient.findB({ type: 'b' }) // correct
// This doesn't.
// It Matches the method name. That's good.
// But it does not check the request type.
client.rpc(grpcClient, 'findA', 1) // should fail
client.rpc(grpcClient, 'findB', 1) // should fail
client.rpc(grpcClient, 'findC', 1) // double fail, the method check works though
I'm able to use the extends keyof generic expression to typecheck the method names. I'm not able to type check the request arguments though.
I could hard code a union as the request argument type.
rpc<GrpcClient, TMethod extends keyof GrpcClient>(client: GrpcClient, method: TMethod, req: ReqA | ReqB) {
The gRPC bindings are dynamically generated and I don't want to maintain a list of possible request types that could change when I regenerate the bindings.
Thoughts?
You can use a conditional type to determine the request type:
type ReqA = { type: 'a' }
type ReqB = { type: 'b' }
class PeopleServiceClient {
findA(request: ReqA) { };
findB(request: ReqB) { };
}
class WrapperClient {
rpc<PeopleServiceClient, TMethod extends keyof PeopleServiceClient>(
client: PeopleServiceClient, method: TMethod,
req: PeopleServiceClient[TMethod] extends (arg: infer T) => void ? T : never) {
}
}
const grpcClient = new PeopleServiceClient()
const client = new WrapperClient()
grpcClient.findA({ type: 'a' }) // correct
grpcClient.findB({ type: 'b' }) // correct
client.rpc(grpcClient, 'findA', {type: 'a'}) // correct
client.rpc(grpcClient, 'findA', {type: 'b'}) // fails
client.rpc(grpcClient, 'findA', 1) // fails
client.rpc(grpcClient, 'findB', 1) // fails
client.rpc(grpcClient, 'findC', 1) // fails

Resources