Home > Enterprise >  How to set references of objects in a typesafe way in typescript?
How to set references of objects in a typesafe way in typescript?

Time:12-29

I want to wire up objects in a typesafe way, with typesafe autocompletion.

Example:

type Animal = {
    owner1: Person,
    owner2: Person,
    numberOfLegs: number
}

type Person = {
    animal1: Animal,
    animal2: Animal,
    name: string
}

let animal : Animal = ...
let person : Person = ...

When I am writing a line like this:

// wire(object1, object2, field1, field2) does
//   object1.field1 = object2
//   object2.field2 = object1
wire(animal, person,...

I want the IDE to help me autocomplete: but the 3rd parameter SHALL BE RESTRICTED to "owner1" or "owner2" and NOT "numberOfLegs", because at that point it is clear for the typescript complier, that person has type Person and in Animal type it is only 'owner1' and 'owner2' fields which have that type, 'numberOfLegs' does have a different type.

Something like this, but unfortunately this does not work:

function wire<
  T1,
  T2,
  F1 extends keyof T1,
  F2 extends keyof T2,
  S1 extends F1 & (T1[F1] extends T2 ? F1 : never),
  S2 extends F2 & (T2[F2] extends T1 ? F2 : never),
>(
  o1: T1,
  o2: T2,
  f1: S1,
  f2: S2,
)
{
  o1[f1] = o2
  o2[f2] = o1
  return
}

CodePudding user response:

I don't think you can make it perfect because we have to make the key type a generic type parameter, but we always want to infer it — if the user supplies it explicitly, they could use a string union type to lie to us. Sadly, I also think you're stuck with a type assertion in the implementation.

But assuming you're okay with those for the DevEx you're looking for, you can do this:

type PickByType<ObjType, PropType> = {
    [Key in keyof ObjType as ObjType[Key] extends PropType ? Key : never]: PropType;
};

// ...

function wire<
    ObjType1,
    ObjType2,
    KeyType1 extends keyof PickByType<ObjType1, ObjType2>,
    KeyType2 extends keyof PickByType<ObjType2, ObjType1>,
>(
    object1: ObjType1,
    object2: ObjType2,
    key1: KeyType1,
    key2: KeyType2,
) {
    (object1 as Record<KeyType1, ObjType2>)[key1] = object2;
    (object2 as Record<KeyType2, ObjType1>)[key2] = object1;
}

If you type wire(animal, person,, you see this in the IDE (if it gets its hints from TypeScript):

Partially-complete call to wire function showing type hints that are restricted as requested

Playground example

CodePudding user response:

I'm not going to worry about type checking inside the implementation of wire(), since I think your main concern here is the behavior from the caller's side. The implementation will probably need some type assertions or the like to suppress errors, since the compiler can't easily understand the sort of type logic you're doing here. See microsoft/TypeScript#48992 for a feature request which would make the implementation type check.

Okay, so here's one approach to the call signature for wire():

declare function wire<T1, T2>(
    o1: T1, o2: T2, 
    k1: KeysMatchingForWrites<T1, T2>, 
    k2: KeysMatchingForWrites<T2, T1>
): void;

type KeysMatchingForWrites<T, V> =
    keyof { [K in keyof T as [V] extends [T[K]] ? K : never]: any };

The KeysMatchingForWrites<T, V> type takes a type T and returns the names of the properties of T to which you could safely write a value of type V. It is implemented via key remapping, where we keep only the keys where V is a subtype of the property type T[K] (the conditional type [V] extends [T[K]] ? K : never produces such keys; I decided to wrap V and T[K] with [...] in order to make it not distributive).

Let's see if it works:

wire(animal, person, "owner1", "animal1");
// function wire<Animal, Person>(
//   o1: Animal, o: Person, k1: "owner1" | "owner2", k2: "animal1" | "animal2"
// ): void

Looks good. The type of k1 is "owner1" | "owner2", and k2 is "animal1" | "animal2" as desired. Neither numberOfLegs nor name are acceptable.

By the way, the reason why KeysMatchingForWrites<T, V> checks that V is a subtype of T[K] and not vice versa is because you are writing, and you want to make sure that it is safe to write as opposed to read. For example:

interface SuperFoo { u: string; }
interface Foo extends SuperFoo { v: Bar }
interface SubFoo extends Foo { w: string; }
declare const foo: Foo;

interface Bar {
    x: SuperFoo, // wider is fine
    y: Foo,
    z: SubFoo; // narrower is not
}
declare const bar: Bar;

// function wire<Foo, Bar>(o1: Foo, o2: Bar, k1: "v", k2: "x" | "y"): void
wire(foo, bar, "v", "x"); // okay, you can assign a Foo to a SuperFoo
wire(foo, bar, "v", "y"); // okay, you can assign a Foo to a Foo
wire(foo, bar, "v", "z"); // error, you cannot necessarily assign a Foo to a SubFoo

In the above, you are writing foo.v to the k2 property of bar. Since foo.v is of type Foo, then you can only safely assign to bar.x and bar.y. Since bar.y is a SuperFoo and every Foo is a SuperFoo, that's fine. But you can't assign to bar.z because that needs to be a SubFoo, and not every Foo is a SubFoo.

Playground link to code

  • Related