n8jadams
n8jadams

Reputation: 1164

Typescript Omit helper not allowing overriding properties

interface ContextValue {
    [K: string]: {
        value: any
        error: boolean
        errorMessage: string
    }
}

export interface GatheringExpChildMachineBaseContext<TYPE> extends Omit<ContextValue, 'id' | 'originalData' | 'order'> {
    id?: string
    originalData: TYPE
    order: number
}

I get the error message TS 2411 Property 'id' of type 'string' is not assignable to string index type '{ value: any; error: boolean; errorMessage: string; }'., and the equivalent messages for originalData and order.

This seems incorrect, as Omit should grab the keys of the ContextValue interface (which is any key,) and omit the union types I supplied 'id' | 'originalData' | 'order' and allow me to override them.

Upvotes: 1

Views: 502

Answers (1)

jcalz
jcalz

Reputation: 328132

TypeScript does not currently support subtraction/negated types, which is what you'd need to say "string but not ("id" | "originalData" | "order")". Omit is implemented by using the Exclude<T, U> utility type on the keys. Exclude<T, U> filters any union members of T which are subtypes of U. Since string is not a union, the filter acts on string as a whole. And since string is not a subtype of a finite union of string literals (it's a supertype, not a subtype), the value of Exclude<string, "id" | "originalData" | "order"> is just string. In some sense, the current version of TypeScript thinks "string minus ("id" | "originalData" | "order") equals string".

The specific functionality you're looking for, where you can make exceptions to an index signature, is the subject of an open issue in GitHub: microsoft/TypeScript#17867. If you want to see this implemented in TypeScript you might want to go to that issue and give it a 👍. Its also marked "awaiting more feedback" so if you think you have a compelling use case for it that isn't covered already, you might want to make a comment detailing it.

For now there are only various workarounds, each of which have an associated pain point.


You can use an intersection to avoid the error, which makes it easy to use an existing value of that type:

interface Extension<T> { id?: string, originalData: T, order: number }
type GECMBCIntersection<T> = ContextValue & Extension<T>

Which makes it easy to use an existing value of that type:

declare const i: GECMBCIntersection<number>;
i.id?.toUpperCase(); // okay
i.originalData.toFixed(); // okay
i.order.toFixed(); // okay
i.somethingElse.errorMessage.toUpperCase(); // okay

But it's not easy to verify that a value is of that type, for the same not-compatible-with-index-signature reason:

const iBad: GECMBCIntersection<number> = {
    originalData: 1,
    order: 2
} // error! originalData is not assignable to index signature

You can represent it as a generic constraint in the actual keys instead of as a concrete type with an index signature:

type GECMBCConstraint<K extends PropertyKey, T> =
    Record<Exclude<K, keyof Extension<any>>, ContextValue[string]> & Extension<T>;

But then you need to carry an extra generic type parameter everywhere and use helper functions to create instances without redundant key names:

const asGECMBCConstraint = <G extends GECMBCConstraint<keyof G, any>>(g: G) => g;
const cGood = asGECMBCConstraint({
    originalData: 1,
    order: 2,
    somethingElse: {
        value: "blork",
        error: true,
        errorMessage: "blork!!",
    }
})

And even then it's hard to work with an actual value when you want to access the erstwhile-index-signature properties:

function cBad<G extends GECMBCConstraint<keyof G, any>>(g: G) {
    if ("somethingElse" in g) {
        g.somethingElse; // error
    }
    const asserted = g as GECMBCIntersection<any>;
    if ("somethingElse" in asserted) {
        asserted.somethingElse.errorMessage.toUpperCase() // okay
    }

}

For now I usually recommend refactoring the code so as not to mix index signatures with other properties. If you can do so, rewrite your type so that it has a ContextValue instead of so that it is a ContextValue. (This is also known as composition over inheritance). Then you get the simpler interface:

interface Easier<T> extends Extension<T> {
    contextValues: ContextValue;
}

which is easy for the compiler to reason about:

const easy: Easier<number> = {
    originalData: 1,
    order: 2,
    contextValues: {
        somethingElse: {
            value: "blork",
            error: true,
            errorMessage: "blork!!",
        }
    }
};

Okay, hope that helps; good luck!

Playground link to code

Upvotes: 2

Related Questions