extend and only specify known properties?

As you may be aware, object types in TypeScript are matched via structural subtyping and are thus open and extendible. If Base is an object type and Sub extends Base, then a Sub is a kind of Base, and you should be allowed to use a Sub wherever a Base is required:

const sub: Sub = thing; 
const base: Base = sub; // okay because every Sub is also a Base

That implies that every known property of Base must be a known property of Sub. But the reverse is not implied: it is not true that every known property of Sub must be a known property of Base. Indeed, you can easily add new known properties to Sub without violating structural subtyping:

interface Base {
    baseProp: string;
}
interface Sub extends Base {
    subProp: number;
}
const thing = { baseProp: "", subProp: 123 };

A Sub is still a kind of Base even though it has extra properties. And so Base‘s properties cannot be restricted to only those known by the compiler.

Object types in TypeScript are therefore open and extendible, not closed or “exact”. There is currently no specific type in TypeScript of the form Exact<Sub> which only allows the known properties of Sub to be present and rejects anything with extra properties. There is a longstanding open request at microsoft/TypeScript#12936 to support such “exact types”, but it’s not something the language currently has.

To complicate matters, object literals do undergo so-called “excess property checking”, where unexpected properties produce compiler warnings. But this is more of a linter rule than a type system feature. Even though the following produces a compiler warning:

const excessProp: Base =
    { baseProp: "", subProp: 123 }; // error!
// ---------------> ~~~~~~~~~~~~
// Object literal may only specify known properties,
// and 'subProp' does not exist in type 'Base'

it doesn’t mean that the value assigned to excessProp is an invalid Base. You can still do this:

const excessProp = { baseProp: "", subProp: 123 };
const stillOkay: Base = excessProp; // no error

So, in some sense, there really is no way to enforcing the constraint you’re looking for in all possible situations. No matter what solution we come up with, someone can always do this:

const x = { a: 1, c: 1 } as const; // okay
const y: { a: 1 } = x; // okay
magicalFunction(y); // okay

That might be unlikely enough that you don’t want to worry about implementing magicalFunction() defensively against it, but it’s something to keep in mind. TypeScript object types are not violated by extra properties, so extra properties might sneak in.


Anyway, while there is currently no way to say that a specific type like ProjectionMap<SomeType> must not contain excess properties, you can make it a self-referencing generic constraint of the form P extends ProjectionMap<SomeType, P>. You can think of P as a candidate type to check against. If P has excess properties somewhere, you can make ProjectionMap<SomeType, P> incompatible with it.

This is basically a workaround to simulate exact types. Instead of specific Exact<T>, we have generic X extends Exactly<T, X> which holds if and only if X is assignable to T but has no excess properties. See this comment on microsoft/TypeScript#12936 for more info.

Here it is:

type Exactly<T, X> = T & Record<Exclude<keyof X, keyof T>, never>

So the type Exactly<T, X> uses the Exclude<T, U> utility type to take the list of keys from X which are not present in T… that is, the excess keys. It uses the Record<K, V> utility type to make an object type whose keys are these excess keys, and whose value is the never type, to which no value in JavaScript is assignable. So for example, Exactly<{a: string}, {a: string, b: number}> would be equivalent to {a: string, b: never}. Having that never in there is what makes X extends Exactly<T, X> fail when X has keys not in T.

Now we can use Exactly recursively inside the ProjectionMap definition (to prohibit excess keys even in nested object types):

type ProjectionMap<T, P extends ProjectionMap<T, P> = T> = Exactly<{
    [K in keyof T]?: T[K] extends object
    ? 1 | ProjectionMap<T[K], P[K]>
    : 1 }, P>

And then Projection doesn’t change:

type Projection<T, P extends ProjectionMap<T>> = {
    [K in keyof T & keyof P]: P[K] extends object
    ? Projection<T[K], P[K]> : T[K]        
} extends infer O ? { [K in keyof O]: O[K] } : never

And magicalFunction constrains P to ProjectionMap<SomeType, P>:

type SomeType = {
    a: string
    b: { a: string, b: string }
}

declare function magicalFunction<
  P extends ProjectionMap<SomeType, P>
>(p: P): Projection<SomeType, P>

Let’s test it:

magicalFunction({ a: 1 }) // {a: string}
magicalFunction({ b: 1 }) // { b: { a: string, b: string } }
magicalFunction({ b: { a: 1 } }) // { b: { a: string } }
magicalFunction({ a: 1, c: 1 }) // error, no 'c' on SomeType
magicalFunction({ b: { a: 1, z: 1 } }) // error, no 'z' on SomeType["b"]

Looks good! All the cases you want to accept are accepted, and all the cases you want to reject are rejected. Of course the magicalFunction(y) issue form before has not gone away, but this implementation might be good enough for the use cases you need to support.

Playground link to code

Leave a Comment