3

If you provide an object with too many properties to a function, you get an error:

type Options = {
    str: "a" | "b",
}

function foo(a: Options) {
    return a.str;
}

const resultA = foo({
    str: "a",
    extraOption: "errors as expected",
//  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^------ Object literal may only specify known properties.
});

This is nice, I want this. But since I know what type I'll be returning based on its input, I want to make the function generic like so:

function bar<T extends Options>(a: T) {
    return a.str as T["str"];
}

But now extra properties are allowed on the input.

const resultB = bar({
    str: "a",
    extraOption: "no error!?",
});

Is there any way to restrict this?

Playground link

Jespertheend
  • 1,814
  • 19
  • 27
  • 2
    Do either of [these approaches](https://tsplay.dev/N5312W) work for you? Note that forbidding excess properties is more of a linter rule than a type safety measure. You can do things to discourage them, but it is impossible to completely prevent them. Let me know if I should write up an answer, or if there's something I'm missing about your question. – jcalz Jan 13 '22 at 04:19
  • Thanks, these are some great solutions! I'm not looking for a super failsafe way to do this, just something that behaves at least somewhat similarly to a non generic function. Feel free to write it up as an answer and I'll gladly accept it. – Jespertheend Jan 13 '22 at 10:38
  • Out of curiosity, what's your use case? Why do you want to forbid excess properties, if you could just not use them? – Parzh from Ukraine Jan 13 '22 at 21:43
  • I have a rather complex system where the properties you can put in change depending on the values of other properties. Having excess properties is not really a huge problem, but I'd like to be a bit more strict on what can be put in so I can be more confident I'm doing the right thing. Since putting in a wrong property probably means I'm doing something I didn't intend to. – Jespertheend Jan 13 '22 at 22:34

1 Answers1

5

Object types in TypeScript are not "sealed"; excess properties are allowed. This enables all sorts of good stuff like interface and class extension, and is just part of TypeScript's structural type system. It is always possible for excess properties to sneak in, so you should probably make sure that your foo() or bar() implementations don't do anything terrible if the function argument has such properties. If you iterate over properties via something like the Object.keys() method, you should not assume that the result will be just known keys. (This is why Object.keys(obj) returns string[])

Of course, throwing away excess properties is often indicative of a problem. If you pass an object literal directly to a function, then any extra properties on that literal will most likely be completely ignored and forgotten about. That could be indicative of an error, and so object literals undergo checking for excess properties:

function foo(a: Options) {
    return a.str;
}

const resultA = foo({
    str: "a",
    extraOption: "errors as expected", // error
});

The reason why this goes away for something like

function bar<T extends Options>(a: T) {
    return a.str as T["str"];
}
const resultB = bar({
    str: "a",
    extraOption: "no error!?",
});

is because generic functions do have the possibility of keeping track of such extra properties:

function barKT<T extends Options>(a: T) {
    return { ...a, andMore: "hello" }
}
const resultKT = barKT({ str: "a", num: Math.PI });
console.log(resultKT.num.toFixed(2)) // "3.14"

But in your version of bar() you are only returning the str property, so excess properties really will be lost. So you'd like an error on excess properties, and you're not getting one.;


But this raises the question: why is bar() generic? If you don't want to allow excess properties and you only care about the one str property, then there's no obvious motivation for T extends Options. If you only want to possibly narrow the type of T["str"], then in fact you only want that part of the input to be generic:

function barC<S extends Options["str"]>(a: { str: S }) {
    return a.str;
}

const resultC = barC({
    str: "a",
    extraOption: "error", // error here
});

But if you really do neet T extends Options for some reason, you can discourage excess properties by making the function input be of a mapped type where excess properties have their property value types mapped to the never type. Since there are no values of type never, the object literal passed in cannot meet that requirement, and they will generate an error:

function barD<T extends Options>(a: 
  { [K in keyof T]: K extends keyof Options ? T[K] : never }
) {
    return a.str
}

const resultD = barD({
    str: "a",
    extraOption: "error", // error here
});

Hooray!


Again, while this discourages excess properties, it does not absolutely prevent them. Structural subtyping requires that you can widen a {str: "a" | "b", extraOption: string} to {str: "a" | "b"}:

const someValue = {
    str: "a",
    extraOption: "error",
} as const;

const someOptions: Options = someValue; // okay

barC(someOptions); // no error
barD(someOptions); // no error

So again, you should make sure your implementations don't assume that excess properties are impossible.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360