Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is it possible to constrain the types of generics to only allow known properties?

Tags:

typescript

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

like image 370
Jespertheend Avatar asked Oct 20 '25 10:10

Jespertheend


1 Answers

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

like image 74
jcalz Avatar answered Oct 22 '25 00:10

jcalz