83

Here is my code

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

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

class GetAllUserData{
    id: number;
}

getAll function returns User[], and each element of array has the name property, even if its return type is GetAllUserData[].

I want to know if it is possible "out of the box" in TypeScript to restrict an object only to properties specified by its type.

Heretic Monkey
  • 11,687
  • 7
  • 53
  • 122
Valera
  • 2,665
  • 2
  • 16
  • 33
  • The return value of the `getAll` function might have other properties because they're being set by `dbQuery` function. TypeScript doesn't remove properties for you, it will just let you know that you should expect the response to have `GetAllUserData` type with id property, other properties might exist there since it's an external function call. – Behrooz Mar 30 '18 at 20:16

9 Answers9

82

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!
GregL
  • 37,147
  • 8
  • 62
  • 67
  • 2
    Thank you! Very clever soluton. Could you please post this code to GitHub Gist under permissive license, so I can freely copy & paste it in my project? – likern Dec 16 '19 at 12:53
  • 2
    @likern Sure! I put it up [here](https://gist.github.com/greglockwood/1610ef83d0726e0e6c021d46cb573e68) with a BSD license. But the [license for all SO answers](https://gist.github.com/greglockwood/1610ef83d0726e0e6c021d46cb573e68) is the "Creative Commons Attribution-Share Alike" license, which is pretty permissive, Just attribute the code using the permalink whenever you copy-and-paste it into your project. – GregL Dec 16 '19 at 20:23
  • 1
    @GregL thanks for sharing this, very useful. I've made an answer based on yours https://stackoverflow.com/a/61076348/2398593 to support arrays as well – maxime1992 Apr 07 '20 at 09:01
  • How could this be used in combination with DeepPartial, allowing for anything that is known but nothing else? – alexeis Apr 30 '20 at 15:34
  • 1
    @GregL, your answer is very elegant, but I think it isn't reliable. I wrote an answer presenting my concern. Please take a look at my answer, and let me know if you have any comments regarding substance or form. – Ben Carp May 30 '20 at 08:17
  • 3
    It's a clever solution but it doesn't even work for the asked example. Try putting your utility functions in `Promise`, where TS starts "type inference" from the objects, and you will see TS would consider subtypes as replaceable. Read https://medium.com/@lemoine.benoit/why-does-typescript-sometimes-fails-to-type-check-extra-properties-fd230ebbc295 for more details as pointed by "Robert Stiffler"'s answer below. – Aidin Nov 06 '20 at 13:20
  • 6
    This works for object literals only; but excess property checking on object literals work anyway, even without such an approach, at least in TS 4.1.3 where I checked this. – Robert Monfera Jan 18 '21 at 00:12
25

Typescript can't restrict extra properties

Unfortunately this isn't currently possible in Typescript, and somewhat contradicts the shape nature of TS type checking.

Answers in this thread that relay on the generic NoExtraProperties are very elegant, but unfortunately they are unreliable, and can result in difficult to detect bugs.

I'll demonstrate with GregL's answer.

// From GregL's answer

type Impossible<K extends keyof any> = {
    [P in K]: never;
 };

 type NoExtraProperties<T, U extends T = T> = U & Impossible<Exclude<keyof U, keyof T>>;

 interface Animal {
    name: string;
    noise: string;
 }

 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}.`);
 }

 function thisIsAsGoodAsICanGetIt<T extends Animal>(animal: NoExtraProperties<Animal, T>): void {
    console.log(`The noise that ${animal.name.toLowerCase()}s make is ${animal.noise}.`);
 }

 const wrong2 = {
    name: 'Rat',
    noise: 'squeak',
    idealScenarios: ['labs', 'storehouses'],
    invalid: true,
 };

 thisWorks(wrong2); // yay, an error!

 thisIsAsGoodAsICanGetIt(wrong2); // yay, an error!

This works if at the time of passing an object to thisWorks/thisIsAsGoodAsICanGet TS recognizes that the object has extra properties. But in TS if it's not an object literal, a value can always have extra properties:

const fun = (animal:Animal) =>{
    thisWorks(animal) // No Error
    thisIsAsGoodAsICanGetIt(animal) // No Error
}

fun(wrong2) // No Error

So, inside thisWorks/thisIsAsGoodAsICanGetIt you can't trust that the animal param doesn't have extra properties.

Solution

Simply use pick (Lodash, Ramda, Underscore).

interface Narrow {
   a: "alpha"
}

interface Wide extends Narrow{
   b: "beta" 
}

const fun = (obj: Narrow) => {
   const narrowKeys = ["a"]
   const narrow = pick(obj, narrowKeys) 
   // Even if obj has extra properties, we know for sure that narrow doesn't
   ...
}
Ben Carp
  • 24,214
  • 9
  • 60
  • 72
  • This is true. The use of a restrictive type is to prevent assigning an object of a known type with extra keys. In other words, it should remind you to use `pick` when assigning. The answer to original question should be a simple NO imo. Typescript doesn't support that out of the box. – Akash Jul 21 '21 at 03:13
19

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;
}
Robert Stiffler
  • 675
  • 3
  • 10
  • 2
    Looks nice! But shouldn't you make the `discriminator` property optional in both, so that we don't actually have to include it when creating an object of that type? – CRice Mar 30 '18 at 20:33
  • 2
    For your use case, that should be okay. There are other use cases (discriminated unions), where you want to be able to tell the type of an object at run time. For those cases, you would want the discriminator to be required. – Robert Stiffler Mar 30 '18 at 20:39
  • Strictly speaking, structural typing does not entail structural subtyping - for example with tuples, `[number, string, string]` is not a subtype of `[number, string]` in TS even though its structure `{0: number, 1: string, 2: string}` contains all of the structure `{0: number, 1: string}`. It is a design decision that TS has structural subtyping for object types, it could have been done differently but the designers thought structural subtyping was more useful. – kaya3 Aug 03 '22 at 15:46
14

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.

CRice
  • 29,968
  • 4
  • 57
  • 70
  • Thanks for the github issue link. I can link to it in my project and know what to wait for. – Aidin Nov 06 '20 at 12:51
5

Following up on GregL's answer, I'd like to add support for arrays and make sure that if you've got one, all the objects in the array have no extra props:

type Impossible<K extends keyof any> = {
  [P in K]: never;
};

export type NoExtraProperties<T, U extends T = T> = U extends Array<infer V>
  ? NoExtraProperties<V>[]
  : U & Impossible<Exclude<keyof U, keyof T>>;

Note: The type recursion is only possible if you've got TS 3.7 (included) or above.

maxime1992
  • 22,502
  • 10
  • 80
  • 121
  • How could this be used in combination with DeepPartial, allowing for anything that is known but nothing else? – alexeis Apr 30 '20 at 15:34
  • Honestly I've lost hope on that one. I've tried to customize it quite a bit to handle really all the cases but it never worked out. There's an issue on TS repo https://github.com/microsoft/TypeScript/issues/12936 please upvote it. Sorry for you question but I have no idea – maxime1992 Apr 30 '20 at 15:57
  • As I wrote to GregL, answer is very elegant, but I think it isn't reliable. I wrote an answer presenting my concern. Please take a look at my answer, and let me know if you have any comments regarding substance or form. – Ben Carp May 30 '20 at 08:20
  • I can't make it work with the question above. Is there something I am missing with the syntax? ``` class GetAllUserData { id: number; } type Impossible = { [P in K]: never; }; type NoExtraProperties = U extends Array ? NoExtraProperties[] : U & Impossible>; export async function getAll(): Promise[]> { return Promise.resolve([ { id: 1, extraProp: "unexpected prop" } // I don't have the error I expect here ]); } ``` – Shrakka Jun 11 '21 at 11:05
3

The accepted answer, with a discriminator, is right. TypeScript uses structural typing instead of nominal typing. It means that the transpiler will check to see if the structure match. Since both classes (could be interface or type) has id of type number it matches, hence interchangeable (this is true one side since User is having more properties.

While this might be good enough, the issue is that at runtime the returned data from your method getAll will contains the name property. Returning more might not be an issue, but could be if you are sending back the information somewhere else.

If you want to restrict the data to only what is defined in the class (interface or type), you have to build or spread a new object manually. Here is how it can look for your example:

function dbQuery(): User[] {
    return [];
}
function getAll(): GetAllUserData[] {
    const users: User[] = dbQuery();
    const usersIDs: GetAllUserData[] = users.map(({id}) => ({id}));
    return usersIDs;
}

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

class GetAllUserData {
    id: number;
}

Without going with the runtime approach of pruning the fields, you could indicate to TypeScript that both classes are different with a private field. The code below won't let you return a User when the return type is set to GetAllUserData

class User {

    id: number;
    name: string;
}

class GetAllUserData {
    private _unique: void;
    id: number;
}
function getAll(): GetAllUserData[] {
    return dbQuery(); // Doesn't compile here!
}
Valera
  • 2,665
  • 2
  • 16
  • 33
Patrick Desjardins
  • 136,852
  • 88
  • 292
  • 341
1

I found this another workaround:

function exactMatch<A extends C, B extends A, C = B>() { }

const a =  { a: "", b: "", c: "" } 
const b =  { a: "", b: "", c: "", e: "" } 
exactMatch<typeof a, typeof b>() //invalid

const c =  { e: "", } 
exactMatch<typeof a, typeof c>() //invalid

const d =  { a: "", b: "", c: "" } 
exactMatch<typeof a, typeof d>() //valid 

const e = {...a,...c}
exactMatch<typeof b, typeof e>() //valid

const f = {...a,...d}
exactMatch<typeof b, typeof f>() //invalid

See the original Post

Link to Playground

Filly
  • 411
  • 2
  • 8
1

When using types instead of interfaces, the property are restricted. At least in the IDE (no runtime check).

Example

type Point = {
  x: number;
  y: number;
}

const somePoint: Point = {
  x: 10,
  y: 22,
  z: 32
}

It throws :

Type '{ x: number; y: number; z: number; }' is not assignable to type 'Point'. Object literal may only specify known properties, and 'z' does not exist in type 'Point'.

I think types are good for defining closed data structures, compared to interfaces. Having the IDE yelling (actually the compiler) when the data does not match exactly the shape is already a great type guardian when developping

Steve B
  • 36,818
  • 21
  • 101
  • 174
1

As an option, you can go with a hack:

const dbQuery = () => [ { name: '', id: 1}];

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

type Exact<T> = {[k: string | number | symbol]: never} & T

type User = {
    id: number;
    name: string;
}

type GetAllUserData = Exact<{
    id: number;
}>

Error this produces:

Type '{ name: string; id: number; }[]' is not assignable to type '({ [k: string]: never; [k: number]: never; [k: symbol]: never; } & { id: number; })[]'.
  Type '{ name: string; id: number; }' is not assignable to type '{ [k: string]: never; [k: number]: never; [k: symbol]: never; } & { id: number; }'.
    Type '{ name: string; id: number; }' is not assignable to type '{ [k: string]: never; [k: number]: never; [k: symbol]: never; }'.
      Property 'name' is incompatible with index signature.
        Type 'string' is not assignable to type 'never'.
Viktor M
  • 387
  • 3
  • 7
  • 1
    Doesn't work; valid inputs are also all rejected. (eg. change the first line to just `const dbQuery = ()=>[{id: 1}];` and it still errors) – Venryx Dec 21 '22 at 02:56