Search code examples
typescripttypescript-generics

Typesafe function to update an object's property where value is of a specific type


Is it possible in Typescript to write a function foo(object, key) that sets an arbitrary string value to object[key] where:

  • key is typechecked as a key of object where object[key] is a string
  • the body of the function doesn't use any unsafe construct like type assertion, type predicate, ts-ignore, etc nor reassignment
  • the assignment is performed using a function update(object, key, value), in the real world this would be an ORM method

?

Attempts:

function foo<T extends Record<K, string>, K extends keyof T>(v: T, k: K) {
    update(v, k, Math.random().toString()) // Type 'string' is not assignable to type 'T[K]'. 'string' is assignable to the constraint of type 'T[K]', but 'T[K]' could be instantiated with a different subtype of constraint 'string'.(2322)
}


function foo2<T extends Record<K, string>, K extends keyof T>(v: T, k: K) {
    const v2: Record<K, string> = v // reassignment (up-casting)
    update(v2, k, Math.random().toString()) // ok but we did a reassignment
}


function update<T, K extends keyof T>(o: T, k: K, v: T[K]) {
  o[k] = v
} 

Playground


Solution

  • A major caveat:

    TypeScript is intentionally unsound when it comes to property writes, so no matter what you do, you can't enforce the kind of safety you're trying to achieve. (The fact that TypeScript has intentional unsoundness can sometimes surprise people, but soundness in and of itself is not a goal of TypeScript, see TypeScript Design Non-Goal #3 and the discussion in microsoft/TypeScript#9825.)

    TypeScript object types are treated as covariant in their property types (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript), meaning that you are allowed to narrow an object type by narrowing one of its property types (see this comment in microsoft/TypeScript#55451). That would be safe if properties were read-only. But since properties are mutable, it is unsafe to allow such narrowing:

    interface Foo {
        w: unknown;
        x: string
        y: number;
        z: string;
    }
    interface Bar extends Foo {
        x: "abc"
    }
    const bar: Bar = { w: true, x: "abc", y: 123, z: "def" };
    const foo: Foo = bar; // <-- allowed!
    
    foo.x = "oops"; // <-- allowed!
    update(foo, "x", "oops"); // <-- allowed!
    

    Here we've assigned "oops" to a property that is required to be "abc". All we can do is discourage this sort of thing, we can't prohibit it. So when it comes to the implementation of any function that tries to discourage bad assignments more strongly, you shouldn't be surprised if you need to do unsafe things to get it to happen.

    To be more explicit: the requirement that "the body of the function doesn't use any unsafe construct like type assertion, type predicate, ts-ignore, etc nor reassignment" is just too strong to be useful in TypeScript. Even if you could manage to jump through the hoops you've erected, it doesn't make TypeScript safe in this way. (And reassignment is only an unsafe construct because property writes are inherently unsafe in TypeScript. Widening by reassignment would be safe if TS were made to be fully sound.)


    Another problem here is that TypeScript doesn't quite let you easily express the type relationship you're describing. You want to say that the type of object[key] is a supertype of string (so it's safe to write to), but the only sorts of constraints that TypeScript has are subtype constraints (so it's safe to read from). You can say V extends string as an upper bound on V, but you can't say V super string as a lower bound on V. For that, you'd need lower-bounded type parameter constraints, as requested in microsoft/TypeScript#14520. For now it's not part of the language and might never be.

    You can use conditional types to emulate lower bound constraints; since V super string is equivalent to string extends V, you can use a conditional type that checks string extends V. Like this:

    declare function setStringProp<
        T extends { [P in K]: string extends T[P] ? unknown : never },
        K extends keyof T
    >(o: T, k: K): void;
       
    setStringProp(bar, "w"); // okay
    setStringProp(bar, "x"); // error
    setStringProp(bar, "y"); // error
    setStringProp(bar, "z"); // okay
    

    Here we're checking that the property type at T[K] is a supertype of string. If so, then we say it must be a subtype of unknown (which is always satisfied). If not, we say it must be a subtype of never (which is only satisfied by never and unlikely to happen in practice). So it fails for "x" because "abc" is not a supertype of string, and it fails for "y" because number is definitely not a supertype of string. And it succeeds for "w" and "z" because both unknown and string are supertypes of string. (The terms "subtype" and "supertype" are considered to be inclusive; every type is both a supertype and subtype of itself. If you want to exclude itself you'd have to say "proper subtype" or "proper supertype".)

    Of course the implementation of setStringProp() needs to just call update() or the like, but that complicated generic conditional type can't be understood by TypeScript. You pretty much need to use one of those type-safety-loosening approaches you've tried to prohibit. In cases like this where there's a break between the type safety needs of the caller and that of the implementer, I tend to write single-call-signature overload functions, since that makes such things fairly explicit without a lot of type assertions:

    // call signature
    function setStringProp<
        T extends { [P in K]: string extends T[P] ? unknown : never },
        K extends keyof T
    >(o: T, k: K): void;
    
    // implementation
    function setStringProp<K extends PropertyKey>(o: Record<K, string>, k: K) {
        update(o, k, Math.random().toString())
    }
    

    That's as close as I can get to type safety around property writes. Again, all this does is discourage the unsafe assignments. Nothing whatsoever can stop this:

    const bar: Bar = { w: true, x: "abc", y: 123, z: "def" };
    const foo: Foo = bar; // <-- allowed!
    setStringProp(foo, "x"); // <-- allowed!
    

    Which is one reason why there's only so much effort one should reasonably put into dealing with this issue. You'll make the front door of your house reasonably resistant to casual burglars by making sure it latches and locks, maybe even with a hard-to-pick deadbolt lock. But will you spend all your money making it completely impenetrable, especially when the window next to the door can be broken with a well-aimed brick? Unless you want to make your entire home an unassailable fortress, at some point you just stop worrying about it and spend your limited resources on more functional home improvements.

    Playground link to code