2

I'm trying to provide an interface that accepts a map of a given type, and uses it for both runtime logic and compile-time typing.

something like:

type SomeType = {
  a: string
  b: { a: string, b: string }
}
magicalFunction({ a: 1 }) // 1. return type is {a: string}
magicalFunction({ b: 1 }) // 2. return type is { b: { a: string, b: string } }
magicalFunction({ b: { a: 1 } }) // 3. return type is { b: { a: string } }
magicalFunction({ a: 1, c: 1 }) // 4. compile-time error since there's no 'c' on SomeType

(in the real world, magicalFunction takes SomeType as a generic parameter. for this question we can assume it's just hard-coded with SomeType.)

I'm getting the first 3 behaviours when using mapped types:

export type ProjectionMap<T> = {
  [k in keyof T]?: T[k] extends object
    ? 1 | ProjectionMap<T[k]>
    : 1

export type Projection<T, P extends ProjectionMap<T>> = {
  [k in keyof T & keyof P]: P[k] extends object
    ? Projection<T[k], P[k]>
    : T[k]
}

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

function magicalFunction<P extends ProjectionMap<SomeType>>(p: P): Projection<SomeType, P> {
  /* using `p` to do some logic and construct something that matches `P` */
  throw new Error("WIP")
}

const res = magicalFunction({ a:1 })
// etc

Problem is I'm not getting a compilation error when specifying extra properties - e.g {a:1, c:1}. Now, it actually makes perfect sense - in that case the compiler infers P as typeof { a:1, c:1 }, which is a valid subclass of the result of ProjectionMap<SomeType> since all fields are optional. But is there some magical incantation that will make this work as I described? A different way to specify the type, or some mapped-types magic?

As far as I can tell, manipulating P when specifying the type for the p parameter breaks type inference. If we assume there's a key filtering type (fails for unknown keys, recursively), then changing the signature to magicalFunction<...>(p: FilterKeys<P, SomeType>): ... makes a call like magicalFunction({a:1}) resolve to magicalFunction<unknown>.

background

my end-goal here is to create a repository class, which is typed to a specific entity, and can perform projection queries against mongo. For type safety I want to get auto-complete for the projection fields, compilation errors when specifying non-existing fields, and a return type that matches the projection.

for example:

class TestClass { num: number, str: string }
const repo = new Repo<TestClass>()
await repo.find(/*query*/, { projection: { num: 1 } }) 
// compile-time type: { num: number}
// runtime instance: { num: <some_concrete_value> }
Ridiculous
  • 443
  • 4
  • 9
  • What’s going on with `Array` in the code shown here? You’re not using it in the calls, are you? If not, then you should either change the calls or remove the array stuff from the types so that the code in the question forms a [mre]. – jcalz Oct 20 '21 at 15:30
  • @jcalz you mean `UnwrapArray`? it's used to allow mapping `{a: { b:boolean}[] }` for example with `{ a: { b: 1} }`. otherwise the props of `a` would be `push`, `pop` etc. – Ridiculous Oct 20 '21 at 15:37
  • Where's `{a: {b: boolean}[]}` or anything like it in your example code? The "minimal" part of a [mre] is supposed to be that you only include code directly relevant to the question. In this case the question seems to be "can I make a generic constraint that prohibits excess keys" and has nothing to do with arrays. I would suggest removing that code so that someone can answer with, for example, [this solution](https://tsplay.dev/Nn40aW) and then you can go off and expand it to [this](https://tsplay.dev/WkjQ0m) yourself. On the other hand... – jcalz Oct 20 '21 at 17:22
  • ...if you think the array type manipulation is central to your question, then your example code that calls `magicalFunction()` should definitely demonstrate it (e.g., maybe your `SomeType` should have arrays in it). It helps people who want to answer if they can test a potential solution against use cases that would make or break your acceptance of it. Personally I doubt the array bit is that central (since your `SomeType` example doesn't include it) and should just be removed. – jcalz Oct 20 '21 at 17:23
  • Anyway, I'll wait to see here which way you decide to [edit] the question and post an answer with an explanation after that. Or you can leave it as-is and maybe someone else will come along and answer. Good luck! – jcalz Oct 20 '21 at 17:27
  • 1
    Thanks for your solution, it's just what I was looking for. feel free to post it as an answer so I can explicitly accept it. I removed the array stuff from the code samples. – Ridiculous Oct 21 '21 at 08:57
  • Okay, answered. Note that in [mapped types](https://www.typescriptlang.org/docs/handbook/2/mapped-types.html) of the form `{[K in XXX]: YYY}`, the `K` is a *type parameter*, and thus it is conventional to represent as a single capital letter (or at least UpperCamelCase as other types) and not lowerCamelCase. That is, `K` and not `k`. This is just convention, and nothing breaks if you use `k`, but it's as unconventional as writing `declare function f(x: t): t` instead of `declare function f(x: T): T`. Both `K` and `T` are type parameters. – jcalz Oct 21 '21 at 19:36

1 Answers1

4

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 from before has not gone away, but this implementation might be good enough for the use cases you need to support.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360