2

Working on learning Typescript and am trying to understand how to enforce types based on an interface.

I have this bit of code

    interface User{
    name:string;
    age:number;
    friends:string[]
    hasPet:boolean
}

function setValue<T>(key:keyof T, value: T[keyof T] ):void{
    console.log(key, value)
    // ...some additonal logic
}

setValue<User>("age", true )  //should throw error but doesn't

I want this interface to enforce the key type when I call setValue. This mostly works, but I can pass any of the available User types into set value.

What I expect is that passing when "age" as the first argument to setValue, the 2nd argument should be restricted to the type of "number". Same for all the other User keys as well.

Thanks for the help!

Scott Z
  • 352
  • 1
  • 7
  • 1
    This is the relevant part of docs https://www.typescriptlang.org/docs/handbook/2/generics.html#using-type-parameters-in-generic-constraints – Akxe Sep 02 '21 at 18:46
  • 1
    Can you explain why you want to specify `User` manually? What can a function that is generic in `T` like that actually do without a value of type `T`? You really need the function to be generic in `K extends keyof T`, but I can't tell if you actually need `T` to be generic or if it can be hardcoded as `User`. See [this code](https://tsplay.dev/mx5pBW) for various options and let me know which if any of those you'd like as an answer. – jcalz Sep 02 '21 at 19:16
  • @jcalz The function doesn't have to be generic, that bit was an experiment as I'm trying to learn TS. I think your `HardcodedUserType` works as I would expect. Thanks! – Scott Z Sep 02 '21 at 19:23

3 Answers3

2

You should inspect this in Typescript playground. The reason it's not throwing an error keyof returns a type representing all the properties keys.

So your function actually looks like:

setValue(key: keyof User, value: string | number | boolean | string[])

Obviously, what you're trying to do fits one of those types.

Where as if you tried to do something like:

let newUser: User;
newUser.age = 'abc'; // Type error
DrZoo
  • 1,574
  • 1
  • 20
  • 31
  • I think I realize that the `keyof` operator is returning all of the available types. I can't figure out how to narrow the possible type of the 2nd argument with the value of the 1st argument. – Scott Z Sep 02 '21 at 19:03
2

For correct type inference, the key must also be a type variable.

Let me guess, but in a real application, you will most likely have a function like

function setValue<T, K extends keyof T>(obj: T, key: K, value: T[K]) {
    obj[key] = value;
}

const user: User = {
    name: 'Den',
    age: 22,
    friends: ['1'],
    hasPet: true,
};

setValue(user, 'age', true); // Error - Argument of type 'boolean' is not assignable to parameter of type 'number'

Because in your example, the function has parameters for the key and value, but there is no object to change.


Another alternative solution if you need a setter function for some reason:

const createValueSetter = <T>(obj: T) => <K extends keyof T>(
    key: K,
    value: T[K]
) => {
    obj[key] = value;
};

const user: User = {
    name: 'Den',
    age: 2,
    friends: ['1'],
    hasPet: true,
};

const userSetter = createValueSetter(user);

userSetter('age', true); // Error - Argument of type 'boolean' is not assignable to parameter of type 'number'
Denwakeup
  • 256
  • 2
  • 4
1

In order for this to work, you want to make the function generic in the type of the key input. Let's alter your example so that it only works for setting properties of User, and make a user of that type to modify with the function:

const user: User = {
  name: "",
  age: 0,
  friends: [],
  hasPet: false
}

function setUserValue<K extends keyof User>(key: K, value: User[K]) {
  user[key] = value;
}

Here you can see that the function is generic in K which has been constrained to keyof User. The key input is of type K, and the value input is of type User[K], an indexed access type meaning "the property type of User at a key of type K". Let's see how it works:

setUserValue("age", true); // error, boolean isn't a number 
setUserValue("age", 25) // okay

Looks good. The compiler infers K to be the literal type "age" and then wants value to be of type User["age"] which is number.

This is the end of the simple version of the answer.


That was the standard solution to this problem; do note that it is still possible to do unsafe things with this function, although they are less likely. For example, when key is of a union type, things can go wrong:

const key = Math.random() < 0.5 ? "age" : "hasPet";
setUserValue(key, true); // no error!  But this has a 50% chance of doing something bad

There is currently no direct way in TypeScript to say that we want K to be exactly one element from the keyof User union and not allow it to be a union type itself. There's a feature request at microsoft/TypeScript#27808 asking for support for that. Right now you need to accept key being a union (or prevent it in a complicated way I won't get into here).

The real problem is that indexed access types T[K] are only safe for reading properties, because T[K1 | K2] becomes T[K1] | T[K2]. But for writing you really want T[K1 | K2] to be treated like T[K1] & T[K2]... that is, unions in the key type should become intersections in the "writing indexed access" type.

You can express such a type function in TypeScript as follows:

type WritingIdx<T, K extends keyof T> =
  { [P in K]: (x: T[P]) => void }[K] extends (x: infer I) => void ? I : never;

(Here I'm using essentially the same technique as here to turn unions to intersections)

And then you could declare setUserValue() to have value be of type WritingIdx<User, K> instead of User[K]:

function setUserValue<K extends keyof User>(key: K, value: WritingIdx<User, K>): void;
function setUserValue<K extends keyof User>(key: K, value: User[K]) {
  user[key] = value;
}

Note that I had to make this a one-call-signature overload because one drawback of WritingIdx<User, K> is that the compiler cannot verify that user[key] = value is safe when value is of that type. So inside the implementation I'm widening it to User[K].

Let's check it out:

setUserValue("age", true); // error, boolean isn't a number 
setUserValue("age", 25) // okay
setUserValue(key, true); //error!

If we imagine that User had some property with overlapping types:

interface User {
  name: string;
  shoeSize: string | number;
}

Here shoeSize can be either string or number, but name can only be a string. Then setUserValue() on both of them should only accept the intersection, which is string:

const nameOrShoeSize = Math.random() < 0.5 ? "name" : "shoeSize"
setUserValue(nameOrShoeSize, "hasToBeAString"); // okay
setUserValue(nameOrShoeSize, 123); // error

So, do you need this more complicated version of the answer? Probably not, if the key parameter is only ever going to be a string literal. Even if unions show up sometimes, you might not want to spend the energy dealing with a complex and pedantic code base. TypeScript generally allows unsafe things in the name of developer convenience, so there are many other "holes" in the type system. But I figured I'd at least talk about the issues involved with a generic property writer function.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360