Home > Back-end >  Use explicit properties of an object which is type annotated
Use explicit properties of an object which is type annotated

Time:06-19

So basically I have a certain structure that some objects should follow and I enforce this structure by adding type annotations. But now I want typescript to use the inferred type rather than the type I set to look at this object and determine its properties. To give a little example, lets say the type looks like this:

type TypeA = {
    a: number;
    b: {
        c: number;
        d?: string;
        e?: boolean;
    }
}

When I create an object I want it to follow this structure, so I do the following:

const myObj: TypeA = {
    a: 1,
    b: {
        c: 2,
        d: 'hello',
    }
}

Note that since the e property is not required, I choose to not use it in myObj. What I would like now is that typescript uses the inferred type of that object as I go on, meaning that I should not be able to access the e property on myObj since I didn't define it there.

So I tried using a onstrained identity function, like this:

function buildTypeA<T extends TypeA>(obj: T) { return obj; }

const myObj = buildTypeA({
    a: 1,
    b: {
        c: 2,
        d: 'hello',
    }
});

This does return the right properties, but unfortunately also allows to add properties that don't exist on the TypeA.

My question now is if there is a way to remove that problem, meaning that I can achieve the behaviour of a contrained identity function but that I am only allowed to use properties that are defined on the type.

CodePudding user response:

You can't really forbid excess properties. TypeScript's structural type system takes the view that a value must have all the properties declared in a type, but may also have properties that have not been declared. This is a good thing, because it lets you extend interfaces without violating them:

interface TypeB extends TypeA {
  h: number;
}

The above means implies that every value of type TypeB is also a value of TypeA. If the extra h property were to violate the TypeA type, then interface and class hierarchies would not form type hierarchies and lots of perfectly valid real world code would be in error.

Another way of saying this is that TypeScript's object types are not exact. Support for exact types has been requested in the longstanding open issue microsoft/TypeScript#12936; but this has not been implemented yet.


What has been implemented is excess property checking, which only considers excess properties to be a problem in places where the compiler will immediately forget about these properties. The problem isn't a type safety error; it's more of a linter rule.

So this is an error:

const oops: TypeA = { a: 1, b: { c: 2 }, h: 4 }; // error

because the compiler cannot track that oops has an h property... and it is assumed that this is more likely to be a programming mistake than an intentional added property.

But this is not an error:

const okay = { a: 1, b: { c: 2 }, h: 4 }; // okay
const stillOkay: TypeA = okay; // okay

despite the fact that stillOkay is equivalent to oops. The difference is that the okay variable exists and the compiler knows that it has an h property. And therefore users of the code have a way to read/write h.

So the reason why your buildTypeA() doesn't forbid excess properties is that the function does keep track of every single property.

Again, excess property warnings are not really type errors. And because you can easily sneak in excess properties via multiple variable assignments, there's no way to forbid them. All you can do is discourage them, and this discouragement should hopefully serve some kind of actual purpose that works well with a structural type system. If you have code that will explode at runtime in the presence of unexpected extra properties, you should consider hardening that code.


Anyway, it is possible to write buildTypeA() in a way that gives something like excess property warnings. But you need to give the generic type parameter a recursive constraint that emulates it.

Here's one possible approach:

type Exactly<C, T> = C extends object ?
  { [K in Exclude<keyof T, keyof C>]: never } &
  { [K in keyof C]: K extends keyof T ? Exactly<C[K], T[K]> : C[K] }
  : C;

function buildTypeA<T extends Exactly<TypeA, T>>(
  obj: T) { return obj; }

The intent is that Exactly<C, T> type takes a constraint type C and a candidate type T, and evaluates to a type such that T extends Exactly<C, T> if and only if T is assignable to C but has no excess properties at any nested depth.

The way this is evaluated is first to check if C is an object-like type. If not, then we just constrain T to C directly, so we don't end up trying to recurse down into the properties of primitives (That's C extends object ? ... : C).

Then we take all properties found in T but not in C (this is Exclude<keyof T, keyof C>). These are the excess properties we want to forbid... so we map these property types to the never type so that the constraint will be violated. In addition, we need to constrain T to C, so for all the properties K in the keys of C we recursively generate Exactly<C[K], T[K]>, so that excess properties can be rejected at all depths. Note that if T fails to have a key K that C has, then we only need to check against C[K].


Let's test it out:

const okay2 = buildTypeA({ a: 1, b: { c: 2 } });  // okay
// const okay2: { a: 1; b: { c: 2; }; }

const badObj = buildTypeA({
  a: 1,
  b: {
    c: 2,
    d: 'hello',
    g: 1 // error
  },
  h: 2 // error
});

Looks good. The compiler only complains about the excess properties. Please note though that such recursive mapped types can have weird edge cases, so you should test thoroughly before adopting this approach directly. Something might need to change.

And also, again, this doesn't stop someone from copying a value with extra properties into a variable that doesn't know about them, and then nothing you do with buildTypeA() will be able to detect that:

const o = {
  a: 1,
  b: {
    c: 2,
    d: 'hello',
    g: 1
  },
  h: 2
};
const p: TypeA = o; // okay
const q = buildTypeA(p); // okay

So I still suggest that you not rely on any object type in TypeScript being truly "exact".

Playground link to code

  • Related