nickf
nickf

Reputation: 546503

TypeScript extra keys in nested objects

My problem can be summed up with this little snippet (here's a larger, interactive example in the Playground):

type X = {x: number};
type Y = {y: number};
type XXY = { x: X } & Y;
let xxy: XXY = {
    x: {
        x: 1,
        notValid: 1   // <--- this is not an error :(
    },
    y: 1
};

Given that X and Y are derived in another way (and so I can't just write the XXY type by hand), how can I make it so that unknown keys in the nested object are treated as invalid?

Upvotes: 2

Views: 368

Answers (2)

Bartel
Bartel

Reputation: 411

Anyone landing here in 2022, check that your tsconfig.json does not contain: "suppressExcessPropertyErrors": true , like in my case. Excess property errors are handled much better since the original post...

Upvotes: 0

jcalz
jcalz

Reputation: 330466

This is a known bug where excess property checking doesn't apply to nested types involving unions and intersections in the way that people expect. Excess property checking is kind of an add-on to the type system that only applies to object literals, so when it doesn't apply, things fall back to the structural subtyping rule where type {a: A, b: B} is a subtype of {a: A}, and so a value of the former type should be assignable to a variable of the latter type. You might want to head over to the issue in Github and give it a 👍 or explain your use case if you think it's more compelling than the ones already listed there. Hopefully there will be a fix someday.

Until then, there are workarounds. The type-level equivalent to excess property checks are so-called exact types, which don't exist in TypeScript as concrete types. There are ways to simulate them using generic helper functions and type inference... in your case it would look something like this:

type Exactly<T, U extends T> = T extends object ?
  { [K in keyof U]: K extends keyof T ? Exactly<T[K], U[K]> : never }
  : T

const asXXY = <T extends XXY>(x: T & Exactly<XXY, T>): T => x;

let xxy = asXXY({
  x: {
    x: 1,
    notValid: 1  // error!
  },
  y: 1
});

That's the error you want, right?

How it works: The helper function asXXY<T extends XXY>(t: T & Exactly<XXY, T>) infers the generic type T to be the type of the passed-in parameter t. Then it tries to evaluate the intersection T & Exactly<XXY, T>. If t is assignable to T & Exactly<XXY, T>, the check succeeds and the function is callable. Otherwise, there will be an error somewhere on t showing where they differ.

And Exactly<T, U extends T> basically walks down recursively through U, keeping it the same as long as it matches T... otherwise it sets the property to never.

Let's expand on this difference for the above case: The inferred type for T was

{ x: { x: number; notValid: number; }; y: number; }

Is that assignable to T & Exactly<XXY, T>? Well, what's Exactly<XXY, T>? It's turns out to be

{ x: { x: number; notValid: never; }; y: number; }

which is what happens when you drill down into type T and notice that notValid can't be found inside XXY.

The intersection T & Exactly<XXY, T> is essentially just Exactly<XXY, T>, so now the compiler is comparing the passed-in parameter to the type

{ x: { x: number; notValid: never; }; y: number; }

which it isn't. The notValid type is a number, not a never... so the compiler complains in exactly the place you want.

Okay, hope that helps. Good luck!

Upvotes: 1

Related Questions