Home > Enterprise >  Why does a nested Omit type lose type safety?
Why does a nested Omit type lose type safety?

Time:11-16

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 } })

Playground

  • Related