TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Allow for optional index signatures (for the sake of `exactOptionalPropertyTypes`)

Open OliverJAsh opened this issue 4 years ago • 11 comments

Bug Report

🔎 Search Terms

exactOptionalPropertyTypes strictoptionalProperties Partial index signature dictionary record

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about exactOptionalPropertyTypes

⏯ Playground Link

Playground link with relevant code

💻 Code

type MyRecord = { x: string; z: string };

const r: Partial<MyRecord> = {
    x: "y",
    // Error as expected ✅
    z: undefined,
};

type MyDict = { [key: string]: string };

const d: Partial<MyDict> = {
    x: "y",
    // Expected error but got none ❌
    z: undefined,
};

🙁 Actual behavior

See code comments above.

🙂 Expected behavior

See code comments above.


This was briefly discussed in https://github.com/microsoft/TypeScript/issues/44524 and https://github.com/microsoft/TypeScript/issues/44421#issuecomment-859053118.

OliverJAsh avatar Dec 01 '21 10:12 OliverJAsh

This is effectively a design limitation. We don't support the notion of optional index signatures (i.e. it isn't possible to specify a ? in the declaration of an index signature) and before --exactOptionalPropertyTypes there was really no reason to. By their nature index signatures represent optional properties, and all an optional index signature would do is add undefined to the type--which you could just do manually. However, with --exactOptionalPropertyTypes we now have two kinds of undefined, and the only way to get the missing-property kind is through the ? modifier on a property declaration. For this reason we may want to consider supporting the ? modifier on index signatures.

ahejlsberg avatar Dec 01 '21 21:12 ahejlsberg

Is there a way to detect that a type is indexed (but not mapped) as a workaround?

Update: I ended up with the following. If anyone stumbles upon this and has an idea for improvement please let me know:

type IsIndexed<T> = {
    [IndexType in string | number | symbol]: IndexType extends keyof T
        ? true
        : false;
}[keyof T];

interface Test {
    prop: string;
}
const value: IsIndexed<string> = false;
const object: IsIndexed<Test> = false;
const mapped: IsIndexed<Record<keyof Test, unknown>> = false;
const indexed: IsIndexed<Record<keyof Test | string, unknown>> = true;
const indexedWithoutString: IsIndexed<Record<number | symbol, unknown>> = true;

sgvictorino avatar May 01 '22 16:05 sgvictorino

If we were able to use Object.values on a Partial type without getting undefined included in the array type, that would be pretty nice. I like to mark index signature values with the undefined possibility, but then when I use Object.values on the object, I have to filter out undefined values that aren't really there. For example (link):

// Use Partial since ranges might not be set for all keys
type RangeDictionary = Partial<{ [key: string]: { start: number, end: number }}>;

function getRangeEnds(rangeDictionary: RangeDictionary) {
  // TypeScript wants me to consider the possibility that range is undefined here
  return Object.values(rangeDictionary).map(range => range.end);
}

jeremybparagon avatar Feb 02 '23 16:02 jeremybparagon

Destructuring + Partial also doesn't work:


function foo(v: Partial<{ a: number }>) {}

function boo({ a }: Partial<{ a: number }>) {
	// Argument of type '{ a: number | undefined; }' is not assignable 
	// to parameter of type 'Partial<{ a: number; }>' with 'exactOptionalPropertyTypes: true'
	foo({ a })
}

playground

Should that be tracked here or should it be a different issue?

unional avatar Apr 29 '23 22:04 unional

I have encountered the same problem and cannot find a solution anywhere. Has anyone managed to solve it?

Szorcu avatar Dec 04 '23 10:12 Szorcu

This is NOT just for the sake of exactOptionalPropertyTypes! It would also make it safer to set properties on an object whose type has an index signature. Currently, TS often just lets you get and set unknown properties on types with [key: string]: ... | undefined, since that covers all unknown properties. For instance, after renaming or removing a field in an interface, I generally don't get TS errors signaling me to rename or remove the field where it's set elsewhere.

This can lead to bugs that I only discover at runtime. Being able to use [key: string]?: ... in an interface (just like [Key in string]?: ..., which can't be used in interfaces) would prevent this, with or without exactOptionalPropertyTypes. Currently I have no alternative if I want to use interfaces (which are enforced by TS's stylistic ESLint config which my project, now regrettably, uses).

GrantGryczan avatar Dec 05 '23 22:12 GrantGryczan

I have a related proposal - is it possible to report with noUncheckedIndexAccess=true just the record (but not arrays)?

Lonli-Lokli avatar Feb 02 '24 11:02 Lonli-Lokli

@OliverJAsh / @ahejlsberg, would another option be to simply not add | undefined to index signatures when EOPT is true? As @ahejlsberg said they are already optional.

I came across this where Partial is applied to a type with some known properties and then index signature for others. The intent was to allow those knowns to be optional but had the side-effect of letting unknown properties be undefined.

A playground showing failed Required<Partial<>> round-trip (for type fully required beforehand).

I am considering workaround for code base I work with to have an alternative Partial implementation that doesn't mutate index signatures.

jason-ha avatar Mar 09 '25 20:03 jason-ha

type IsIndexed<T> = { [IndexType in string | number | symbol]: IndexType extends keyof T ? true : false; }[keyof T];

@sgvictorino, I was considering writing a replacement Partial to avoid impacting index signatures. IsIndexed would be a piece of that but as proposed it does not detect key remapping over index signatures which might produce Record<``key${string}``, unknown>. Such a type does fall prey to the Partial issue.

We need Partial replacement to have key2: undefined in below reported as a problem:

const keyStringRecordOfNumbers: Required<Partial<Record<`key${string}`, number>>> = { key1: 0, key2: undefined };

jason-ha avatar Mar 09 '25 21:03 jason-ha

You can do that with union type and OptionalProps

unional avatar Mar 09 '25 22:03 unional

@unional, thanks, but what is the exact suggestion? How would you write AltPartial in here:

type AltPartial<T> = Partial<T>; // <- replace Partial<T>
// @ts-expect-error Type 'undefined' is not assignable to type 'number'.
const keyStringRecordOfNumbers: Required<AltPartial<Record<`key${string}`, number>>> = { key1: 0, key2: undefined };

jason-ha avatar Mar 10 '25 19:03 jason-ha

The fact that something as basic and idiomatic-sounding as Partial<Record<string, Foo>> is broken by default is just... 😢💔

Here is my solution:

type BetterPartial<T> = Partial<Record<any, any>> extends T ? T : Partial<T>

type MyRecord = {
  bar: string
  baz: string
}

type MyDict = Record<string, MyRecord>

type BadPartialDict = Partial<MyDict> // { [x: string]: MyRecord | undefined } 👎😢

type MyPartialRecord = BetterPartial<MyRecord> // { bar?: string; baz?: string } 🙂👍

type MyPartialDict = BetterPartial<MyDict> // { [x: string]: MyRecord } 🙂👍

In other words, if T is already a Record type, don't alter the type of it's members. (records are already "partial" in nature.)

Note that this only works with --exactOptionalPropertyTypes!

If you don't have that option enabled, this solution is even more broken than Partial with the default settings.

Playground here

I'm sure there's some weird edge case where this doesn't work? 🤔

mindplay-dk avatar Jul 04 '25 11:07 mindplay-dk

@unional

Destructuring + Partial also doesn't work:

function foo(v: Partial<{ a: number }>) {}

function boo({ a }: Partial<{ a: number }>) {
	// Argument of type '{ a: number | undefined; }' is not assignable 
	// to parameter of type 'Partial<{ a: number; }>' with 'exactOptionalPropertyTypes: true'
	foo({ a })
}

playground

Should that be tracked here or should it be a different issue?

That is the intended behaviour, as a is always present (even when undefined) in the call to foo, the fix would be to do:

function boo({ a }: Partial<{ a: number }>) {
	foo(a === undefined ? {} : { a });
}

or:

function boo({ a }: Partial<{ a: number }>) {
	const v = {};
	if (a !== undefined) {
		v.a = a;
	}
	foo(v);
}

or even:

function define<P extends PropertyKey, V>(
	obj: object,
	prop: P,
	value: V,
): asserts obj is { [K in P]: V } {
	Object.defineProperty(obj, prop, {
		__proto__: null,
		value,
		writable: true,
		enumerable: true,
		configurable: true,
	});
}

function boo({ a }: Partial<{ a: number }>) {
	const v = {};
	if (a !== undefined) {
		define(v, "a", a);
	}
	foo(v);
}

ExE-Boss avatar Jul 06 '25 17:07 ExE-Boss