Search code examples
typescript

How to use (or mimic) the behaviour of `satisfies` in a function argument?


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.


Solution

  • 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.

    Playground link to code