Is it possible to restrict typescript object to contain only properties defined by its class?

I figured out a way, using built-in types available since TypeScript version 3, to ensure that an object passed to a function does not contain any properties beyond those in a specified (object) type.

// First, define a type that, when passed a union of keys, creates an object which 
// cannot have those properties. I couldn't find a way to use this type directly,
// but it can be used with the below type.
type Impossible<K extends keyof any> = {
  [P in K]: never;
};

// The secret sauce! Provide it the type that contains only the properties you want,
// and then a type that extends that type, based on what the caller provided
// using generics.
type NoExtraProperties<T, U extends T = T> = U & Impossible<Exclude<keyof U, keyof T>>;

// Now let's try it out!

// A simple type to work with
interface Animal {
  name: string;
  noise: string;
}

// This works, but I agree the type is pretty gross. But it might make it easier
// to see how this works.
//
// Whatever is passed to the function has to at least satisfy the Animal contract
// (the <T extends Animal> part), but then we intersect whatever type that is
// with an Impossible type which has only the keys on it that don't exist on Animal.
// The result is that the keys that don't exist on Animal have a type of `never`,
// so if they exist, they get flagged as an error!
function thisWorks<T extends Animal>(animal: T & Impossible<Exclude<keyof T, keyof Animal>>): void {
  console.log(`The noise that ${animal.name.toLowerCase()}s make is ${animal.noise}.`);
}

// This is the best I could reduce it to, using the NoExtraProperties<> type above.
// Functions which use this technique will need to all follow this formula.
function thisIsAsGoodAsICanGetIt<T extends Animal>(animal: NoExtraProperties<Animal, T>): void {
  console.log(`The noise that ${animal.name.toLowerCase()}s make is ${animal.noise}.`);
}

// It works for variables defined as the type
const okay: NoExtraProperties<Animal> = {
  name: 'Dog',
  noise: 'bark',
};

const wrong1: NoExtraProperties<Animal> = {
  name: 'Cat',
  noise: 'meow'
  betterThanDogs: false, // look, an error!
};

// What happens if we try to bypass the "Excess Properties Check" done on object literals
// by assigning it to a variable with no explicit type?
const wrong2 = {
  name: 'Rat',
  noise: 'squeak',
  idealScenarios: ['labs', 'storehouses'],
  invalid: true,
};

thisWorks(okay);
thisWorks(wrong1); // doesn't flag it as an error here, but does flag it above
thisWorks(wrong2); // yay, an error!

thisIsAsGoodAsICanGetIt(okay);
thisIsAsGoodAsICanGetIt(wrong1); // no error, but error above, so okay
thisIsAsGoodAsICanGetIt(wrong2); // yay, an error!

Typescript uses structural typing instead of nominal typing to determine type equality. This means that a type definition is really just the "shape" of a object of that type. It also means that any types which shares a subset of another type's "shape" is implicitly a subclass of that type.

In your example, because a User has all of the properties of GetAllUserData, User is implicitly a subtype of GetAllUserData.

To solve this problem, you can add a dummy property specifically to make your two classes different from one another. This type of property is called a discriminator. (Search for discriminated union here).

Your code might look like this. The name of the discriminator property is not important. Doing this will produce a type check error like you want.

async function getAll(): Promise<GetAllUserData[]> {
  return await dbQuery(); // dbQuery returns User[]
}

class User {
  discriminator: 'User';
  id: number;
  name: string;
}

class GetAllUserData {
  discriminator: 'GetAllUserData';
  id: number;
}

I don't think it's possible with the code structure you have. Typescript does have excess property checks, which sounds like what you're after, but they only work for object literals. From those docs:

Object literals get special treatment and undergo excess property checking when assigning them to other variables, or passing them as arguments.

But returned variables will not undergo that check. So while

function returnUserData(): GetAllUserData {
    return {id: 1, name: "John Doe"};
}

Will produce an error "Object literal may only specify known properties", the code:

function returnUserData(): GetAllUserData {
    const user = {id: 1, name: "John Doe"};
    return user;
}

Will not produce any errors, since it returns a variable and not the object literal itself.

So for your case, since getAll isn't returning a literal, typescript won't do the excess property check.

Final Note: There is an issue for "Exact Types" which if ever implemented would allow for the kind of check you want here.

Tags:

Typescript