Search code examples
typescripttypescript-genericstype-level-computation

Typed heterogenous object insert, preventing old keys from being overwritten statically


I'd like to add a new field to heterogeneous object, knowing at compile time that the key is not present (or at least not present in the type of the object). The purpose of this is to keep track of initialised resources and their types st you can't access an uninitialised resource in a typesafe way, and you know all initialised resources statically.

Ideally, the function would look like this:

type FreshKey<A,K> = K extends keyof A ? never : K

const f <A, K extends string, v> =  (x: A) 
                                 => (k: FreshKey<A,K>, v: V)
                                 => A & { [k: K]: V} = ...

With usage being something like this:

const z = {}
const y = f(z)("foo", []);
const x = f(y)("bar", 3);
const v = f(x)("baz", "");

const v2 = f(x)("foo", []) // shouldn't typecheck because "foo" is already present

The problem is that I can't even describe the resulting type of f in a way that typescript is happy with. I hit typescript[1337] (An index signature parameter type cannot be a literal type of generic type...).

The ideal implementation would typecheck too, but I'd be happy to just cast it


Solution

  • Create a generic type to ensure our provided key is unqiue

    type EnsureFreshKey<OBJECT extends object, KEY extends string | number | symbol> = KEY extends keyof OBJECT ? never : KEY;
    

    Create a function to handle adding the new key

    function AddNewKey<OBJECT extends object>(a: OBJECT) {
      return <NEW_KEY extends string | number | symbol, VALUE>(key: EnsureFreshKey<OBJECT, NEW_KEY>, value: VALUE) => {
        // Check for KEY uniqueness at runtime
        if (key in a && a[key as unknown as keyof OBJECT] !== undefined) {
          throw new Error(`Key "${String(key)}" already exists in object ${JSON.stringify(a)}`)
        }
        
        // You can decide whether to create a new object or just assign
        // the key onto the existing object
        return {
          ...a,
          [key]: value
        } as OBJECT & { [key in NEW_KEY]: VALUE }
      }
    }
    

    Note:

    This piece of code will result in never if NEW_KEY already exists in OBJECT. And your variable, NEW_KEY should never be of type never

    key: EnsureFreshKey<A, FK>
    

    Test Cases

    const a = {}
    const b = AddNewKey(z)("foo", []);
    const c = AddNewKey(y)("bar", 3);
    const d = AddNewKey(x)("baz", "");
    const e = AddNewKey(x)("foo", []); // correctly throws an error