2

Let's say I have

class Foo {
   readonly a!: string
   readonly b!: string
   readonly c!: string
}

then I create a method with the parameter with

  Partial<Foo>

problem is I also have

class Bar {
   readonly a!: string
   readonly d!: string
}

I can pass Bar to the method that takes Partial<Foo>, but this is not what I intend.

what I want is some kind of AllowOnlyKeysFrom<Partial<Foo>>, you could still have 0 keys, or 1 key, or all the keys, but Bar wouldn't match because it has a key that's not in foo.

I've tried working with a number of libraries, including ts-essentials, ts-typedefs, and ts-toolbelt but haven't been able to figure out a solution.

Is their any way to get the behavior I want so the compiler will prevent me from passing completely bad objects to mymethod( part: Partial<Foo> )?

xenoterracide
  • 16,274
  • 24
  • 118
  • 243
  • https://stackoverflow.com/questions/49580725/is-it-possible-to-restrict-typescript-object-to-contain-only-properties-defined may be relevant. I tried `NoExtraProperties>` from the top answer but the `Partial` type stops it from working as intended. The other solutions on that page involving discriminator properties are probably your best bet. – Zwei Jun 05 '20 at 03:42

2 Answers2

3

You want exact types, which don't really exist in TypeScript. Object types in TypeScript are considered "open" or "extendible"; a type like {a?: string, b?: string, c?: string} only specifies what kind of properties are at the a, b, and c keys. It does not prohibit other keys at all. So a value like {a: "", b: "", c: "", d: 12345} is valid. And Bar is therefore a valid Partial<Foo>.

Note that this is generally a desirable feature, since it allows interface and class extension:

interface X {
    x: string;
}
interface Y extends X {
    y: number;
}

Here, Y extends X means that every Y is an X. This leads directly to the kind of excess property widening you're not happy about:

const y: Y = { x: "", y: 1 }
const x: X = y; // okay

There are some workarounds that approximate exact types, which might behave well enough for your purposes, but can all be circumvented because the compiler will always let you widen a value to a type that doesn't know about the offending keys.


My preferred workaround is to make myMethod() generic in the Partial<Foo>-like type T that part will be. You can constrain T so that every known property in it needs to be a property from Foo, and if there are any extra properties they must be never. Like this:

mymethod<T extends { [K in keyof T]: K extends keyof Foo ? Foo[K] : never }>(
    part: T
) { 
    console.log(part);
}

And test it out:

obj.mymethod({a: ""}) // okay

const bar: Bar = { a: "", d: "" };
obj.mymethod(bar); // error!
// Types of property 'd' are incompatible.

Hooray, it prevents a Bar! And that's great if you hand objects to mymethod which are known to have excess properties. But nothing will stop this:

const partialFoo: Partial<Foo> = bar; // valid assignment
obj.mymethod(partialFoo); // no error!

Here, partialFoo is explicitly annotated as a Partial<Foo>, and bar is assigned to it. That succeeds because types in TypeScript are open, not exact. And the compiler has completely forgotten that partialFoo came from a Bar; it only sees it as Partial<Foo>. So the keys known to the compiler are fine, and the offending key is unknown to the compiler, so it doesn't catch it.


At this point I'd say you might want to step back and consider working with TypeScript's type system instead of against it. If TypeScript treats object types as open, your code should do so as well, by explicitly operating only on the known keys. Imagine we have a function pluck() which produces an object with only the keys we specify:

function pluck<T, K extends keyof T>(t: T, ...k: K[]): Pick<T, K> {
    return k.reduce((a, k) => (k in t && (a[k] = t[k]), a), {} as Pick<T, K>);
}

And then make a version of mymethod() that plucks the a, b, and c properties from part before operating on it:

mySafeMethod(part: Partial<Foo>) {
    this.mymethod(pluck(part, "a", "b", "c"));
}

In this case, it's no longer a problem if you pass in a Bar:

obj.mySafeMethod(bar); // { a: "" };

The runtime code expects that part might have more properties than a, b, and c, and explicitly ignores them instead of, for example, iterating all the existing keys with Object.keys() or for..in. This might be less idiomatic JavaScript, but it does make the compiler easier to deal with.


Okay, hope one of those ideas helps you; good luck!

Playground link to code


UPDATE

TypeScript doesn't really "get" exact types, especially unspecified generic ones like what you see in the implementation of mymethod. So inside mymethod the compiler doesn't know much about part. There are ways to deal with this... one uglier way is to intersect the exact type with the open type so the compiler can at least know that T is a Partial<Foo> as well as the other stuff it can't verify:

mymethod<T extends Partial<Foo> & 
  { [K in keyof T]: K extends keyof Foo ? Foo[K] : never }
>(
    part: T
) {
    console.log(part);
    part.b?.toUpperCase(); // okay
}

Does that work for you?

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • had a problem with `mymethod`, accessing the properties inside of the method were resulting in a compiletime failure. – xenoterracide Jun 05 '20 at 13:13
  • yeah, that works.... gotta figure out how to make that long blob of code into a generic type... and I have some other issues... which might internally be solved with casting of that... – xenoterracide Jun 05 '20 at 13:46
  • You can always use an overload to separate the call signature from the implementation signature, like `mymethod(part: T): void;` for the call signature and then `mymethod(part: Partial) {...}` for the implementation – jcalz Jun 05 '20 at 13:56
0

@jcalz answer is great but isn't what I ended up implementing, it lead me there though.

type FooPart = Opaque<Partial<Foo>>;

Opaque, in this case, comes from type-fest and forces you to define that you want that type when creating it, and only allows keys defined in that type.

foo(): Foo { 
   return { a: 'test' } as FooPart;
}

where

foo(): Foo { 
   return { a: 'test', d: 'not valid' } as FooPart;
}

would fail.

yuriy636
  • 11,171
  • 5
  • 37
  • 42
xenoterracide
  • 16,274
  • 24
  • 118
  • 243