TypeScript does not check a nested type when I use Omit
on the type. See register
vs registerWithOmit
in this example:
export type CaseOptions = {
caseA: { a: number };
caseB: { b: number };
};
type Options = { name: string } & (Omit<CaseOptions, 'caseA'> | Omit<CaseOptions, 'caseB'>);
function register(options: Options) {}
// As expected: this fails as caseA is invalid
register({ name: 'myName', caseA: { b: 1 } });
function registerWithOmit(options: Omit<Options, 'name'>) {}
// Should fail because caseA is invalid
registerWithOmit({ caseA: { b: 1 } });
Why is that the case?
This could be solved differently, e.g., with a type guard, but I am interested in why the type system behaves this way.
Try it here: TS-Playground
CodePudding user response:
It's quite hard to grasp what is happening if we can't actually see what Options
is. We can make TypeScript show the type in full with infer
like this:
type Options = ({ name: string } & (Omit<CaseOptions, 'caseA'> | Omit<CaseOptions, 'caseB'>)) extends infer O ? { [K in keyof O]: O[K] } : never;
Then we'll see that the type Options
is actually
{
name: string;
caseB: {
b: number;
};
} | {
name: string;
caseA: {
a: number;
};
}
keyof
can be a trap to newcomers. One might think that keyof
would get the keys of Options
as "name" | "caseB" | "caseA"
, but there's something you need to consider first! Imagine we have a variable key
that is of type keyof Options
. That means indexing into something of type Options
should be safe:
options[key] // should be OK
But wait, if keyof Options
gave you "name" | "caseB" | "caseA"
, then it's no longer safe, since this could be possible!
options["caseA"] // unsafe, since caseA is not guaranteed to exist
The only safe key to use is name
since it exists on both members in the union. That is why keyof
actually only gets the shared keys. In this case, it's only "name"
.
The next step in understanding why this doesn't work as expected is in the definition of Omit
:
type Omit<T, K extends string | number | symbol> = { [P in Exclude<keyof T, K>]: T[P]; }
If we then "expand" your usage of Omit<Options, "name">
, we get this:
{ [P in Exclude<"name", "name">]: Options[P] }
Since we are excluding "name"
from "name"
, we get never
! And a mapped type over never
is really just {}
, the "empty object type". However, this type actually has a quirk... Anything that is not null or undefined is assignable to {}
, which is why the following works:
// same as
// function registerWithOmit(options: {}) { }
function registerWithOmit(options: Omit<Options, 'name'>) { }
// This is OK since anything that is not null or undefined is assignable to {}
registerWithOmit({ caseA: { b: 1 } })
As such, to get around this, you will need an Omit
that distributes over a union and operates on each of its members, instead of operating on the entire union at once. This is pretty easy to do, thanks to distributive conditional types:
type DistributiveOmit<T, K extends keyof T> = T extends T ? Omit<T, K> : never;
We use T extends T
to distribute over the union, and then use each member (still T
, I know, it's a bit confusing) in Omit
. Essentially, if we use DistributiveOmit<A | B, "name">
, it will "expand to"
(A extends A | B ? Omit<A, "name"> : never) | (B extends A | B ? Omit<B, "name"> : never)
which simplifies to
Omit<A, "name"> | Omit<B, "name">
Then it'll work as expected:
// Type of `options` parameter is
// {
// caseB: {
// b: number;
// };
// } | {
// caseA {
// a: number;
// };
// }
function registerWithOmit(options: DistributiveOmit<Options, 'name'>) {}
// Should fail because caseA is invalid
registerWithOmit({ caseA: { b: 1 } })