I am trying to create a function that accepts a generic type and preserves the literal values of its contents, but does not allow any extra keys.
The typescript satisfies
keyword does exactly what I need - but from what I can see it is only possible to use it while defining a literal type rather than as a function argument.
Here is a bare-bones example of what I mean:
type Foo = { a: number };
const foo = <T extends Foo>(x: T) => x;
foo({ a: 1, wrong: 2 }) // typechecks, but i don't want it to
foo({ a: 1, wrong: 2} satisfies Foo) // causes the type error i want
I've tried all sorts of acrobatics to get this to work to no avail. However after posting a feature request on the Typescript repo I've been pointed to a new feature of Typescript that has allowed me to get something working (see comment).
The solution I ended up with was this monstrosity:
type OptionalKeys<T extends Record<string, unknown>> = {
// eslint-disable-next-line
[P in keyof T]: {} extends Pick<T, P> ? P : never;
}[keyof T];
type RequiredKeys<T extends Record<string, unknown>> = {
// eslint-disable-next-line
[P in keyof T]: {} extends Pick<T, P> ? never : P;
}[keyof T];
type Satisfies<T, Base> =
// recur if both the generic type and its base are records
T extends Record<string, unknown>
? Base extends Record<string, unknown>
// this check is to make sure i don't intersect with {}, allowing any keys
? (keyof T & RequiredKeys<Base> extends never
? unknown
: {
[K in keyof T & RequiredKeys<Base>]: Satisfies<
T[K],
Base[K]
>;
}) &
// this check is to make sure i don't intersect with {}, allowing any keys
(keyof T & OptionalKeys<Base> extends never
? unknown
: {
[K in keyof T & OptionalKeys<Base>]?: Satisfies<
T[K],
Base[K]
>;
})
// if the generic type is a record but the base type isn't, something has gone wrong
: never
: T extends (infer TE)[]
? Base extends (infer BE)[]
? Satisfies<TE, BE>[]
// if the generic type is an array but the base type isn't, something has gone wrong
: never
// the type is a scalar so no need to recur
: T;
Here is a ts playground that demonstrates this solution in action, by causing the correct errors for the following examples:
type Foo = { a: { b?: number }[] };
const foo = <T extends Foo>(x: Satisfies<T, Foo>) => x;
foo({ a: [{}] }); // typechecks
foo({ a: [{ b: 3 }] }); // typechecks
foo({ x: 2 }); // error
foo({ a: [{}], x: 2 }); // error
foo({ a: [{ b: 2, x: 3 }] }); // error
foo({ a: [{ x: 3 }] }); // error
Any suggestions on how to clean this up or otherwise improve it would be extremely appreciated! I'm well out of my comfort zone with this.
You're looking for so-called "exact types" (terminology is from Flow) where excess properties are considered errors. See microsoft/TypeScript#12936. TypeScript doesn't support these; all of its types are "inexact" to allow for structural typing. There is a feature that warns on excess properties in object literals in some situations, but this is more of a linter warning and is not actually part of the type system.
You can use generics to simulate exact types by wring something like T extends Exactly<T, U>
which will only work if T
is "exactly" U
, meaning it doesn't have any extra properties. You seem to care about this recursively, so we can write a DeepExactly<T, U>
where all objectlike properties of T
are checked "exactly" against the properties of U
.
Here's one way to write this:
type DeepExactly<T, U> =
T extends object ? U extends object ?
{ [K in keyof T]: K extends keyof U ? DeepExactly<T[K], U[K]> : never } :
never : U
We're basically using mapped types to convert any excess properties to never
, which is very likely not to be compatible. Let's test it out:
type Foo = { a: { b?: number }[] };
const foo = <T extends Foo>(x: T extends DeepExactly<T, Foo> ? T : DeepExactly<T, Foo>) => x;
foo({ a: [{}] }); // typechecks
foo({ a: [{ b: 3 }] }); // typechecks
foo({ x: 2 }); // error
foo({ a: [{}], x: 2 }); // error
foo({ a: [{ b: 2, x: 3 }] }); // error
foo({ a: [{ x: 3 }] }); // error
Looks good!
Note that this isn't foolproof. TypeScript really cannot model exact types, so nothing whatsoever can ever, even in principle, stop this:
const x = { a: [{ b: 3, e: "haha" }], b: "hello", c: "whoopsiedoodle" };
const y: Foo = x;
foo(y) // okay
In order for that to be prevented, you'd need a type that was Exact<Foo>
or DeepExact<Foo>
that would literally prohibit excess properties. Which is why microsoft/TypeScript#12936 is still open.