Buggy behavior of Typescript extends with a readonly type stripped of its readonly modifier (bug or limitation?)

61 views Asked by At

EDIT: opened a bug report: https://github.com/microsoft/TypeScript/issues/52267 for this, as the behavior differs across various TS versions.

I have a use case for deep "exact" types as it relates to a database, and I wrote a type to check for this. The type doesn't allow any extra properties, and thus is a middle ground between a simple extends check and an equals check (using the well known Equal type).

The user should be able to pass a readonly type created using the as const modifier, or a type alias itself, to be checked for validity before putting it/updating something in the database.

The crux of the issue I'm facing is I'm getting buggy (I think?) behavior when using a type alias vs. when using the as const syntax. My "exact" type below basically sets any value in the type to never that is invalid (allowing union support, because never will disappear). What leads me to believe this may be a bug is the depth of the object matters. At two levels deep, everything is OK, but at three levels deep, things break.

I apologize, this is going to be a pretty in depth code sample below, but is the minimal reproducible example I could create. If this is expected, I'd love to know why. Thanks in advance.

Playground also (includes helpful two-slash queries, easy to see at a glance where it's failing).

// utilities
type IsNever<T> = [T] extends [never] ? true : false;
type DeepSimplifyObject<T> =
  T extends object
  ? (
    {
      [K in keyof T]: DeepSimplifyObject<T[K]>
    }
  )
  : T;
export type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> };

type DeepValidateArray<Arr extends any[], Shape> = {
  [K in keyof Arr]: DeepValidateShapev2<Arr[K], Shape[K & keyof Shape]>;
};
type DeepValidateShapev2<Obj, Shape> =
  IsNever<Obj> extends true
  ? never
  : (
    Obj extends Shape // first we check if all the required fields are on Obj, and the correct types
    ? (
      Shape extends any // next, we must distribute Shape, because if it is a union, keyof Shape could do all sorts of wonky things. Example: Shape is { a: string } | { b: string } and Obj is { a: string }. This should pass our validation, but because keyof Shape is never, the Exclude below evaluates to "a", which does not extends never, thus causing a failure.
      ? (
        Obj extends any[] // delegate this to a helper type, hopefully allowing it to preserve an array type, instead of doing all sorts of annoying things and turning it into an object
        ? DeepValidateArray<Obj, Shape> // this must come before the Exclude thing below, because Obj might be a tuple, while Shape is an array, resulting in a `${number}` key not being excluded
        : (
          IsNever<Exclude<keyof Obj, keyof Shape>> extends true // make sure Obj doesn't have an extra keys. It might, because of the distributing we do for Shape above, but the nice thing about using never is that it'll disappear in the union
          ? (
            Obj extends object // we should have gotten rid of all the types that extend object but that aren't typical "objects" above
            ? {
              [K in keyof Obj]: K extends keyof Shape ? DeepValidateShapev2<Obj[K], Shape[K]> : never;
            }
            : Obj // Obj must be a boolean, null, bigint, undefined or something else (not sure what that'd be though)
          )
          : never
        )
      )
      : never
    )
    : never
  );

// Simple boolean helper
export type DeepValidateShapev2WithBooleanResult<Obj, Shape> = Obj extends DeepValidateShapev2<Obj, Shape> ? true : false;

// We want all objects/types to conform to this type with no extra properties
type ShapeToValidate = {
  topLevel: {
    data?: {
      myTuple: [{ tup1: null }] | null;
    };
  }
};
const simpleUpdateItem = {
  topLevel: {
    data: {
      myTuple: [{ tup1: null, extra: '' }]
    }
  }
} as const;
// A type computed from the object above with readonly removed and run through a simplification type to resolve all nested types. Used for testing below
type _simpleUpdateItem = typeof simpleUpdateItem;
type SimpleUpdateItem = DeepSimplifyObject<DeepWriteable<_simpleUpdateItem>>;
// Just a straightforward type alias used for testing below
type SimpleUpdateItemFromTypeAlias = {
  topLevel: {
    data: {
      myTuple: [{
        tup1: null;
        extra: "";
      }];
    };
  };
};

// hovering over SimpleUpdateItem and SimpleUpdateItemFromTypeAlias look identical, and the Equal type helper also thinks they are equal
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
  ? true
  : false;
type areEqual = Equal<SimpleUpdateItem, SimpleUpdateItemFromTypeAlias>;
type areNotEqual = Equal<_simpleUpdateItem, SimpleUpdateItemFromTypeAlias>;

// What SHOULD happen is some of the values in the type will turn to never, making the original type not extend the result anymore if it was invalid.
type DeepValidateResult = DeepValidateShapev2<SimpleUpdateItem, ShapeToValidate>;
type DeepValidateTypeAliasResult = {
    topLevel: {
        data: {
            myTuple: [never];
        };
    };
};
type areTheseEqual = Equal<DeepValidateResult, DeepValidateTypeAliasResult>;
// TRUE

// What the heck??? Why does the type created from the as const object (SimpleUpdateItem) not produce false, like the SimpleUpdateItemFromTypeAlias type directly written above?
type DeepValidateBooleanExtendsCheckWithAsConstType = DeepValidateShapev2WithBooleanResult<SimpleUpdateItem, ShapeToValidate>;
//   TRUE
type CompletelyStandaloneExtendsCheckWithAsConstType = SimpleUpdateItem extends DeepValidateResult ? true : false;
//   TRUE

// These are all what I'd expect
type AllInlineCheckWithAsConstType = SimpleUpdateItem extends DeepValidateTypeAliasResult ? true : false;
//   FALSE

type DeepValidateBooleanExtendsCheckWithTypeAlias = DeepValidateShapev2WithBooleanResult<SimpleUpdateItemFromTypeAlias, ShapeToValidate>;
//   FALSE
type CompletelyStandaloneExtendsCheckWithTypeAlias = SimpleUpdateItemFromTypeAlias extends DeepValidateResult ? true : false;
//   FALSE
type AllInlineCheckWithTypeAlias = SimpleUpdateItemFromTypeAlias extends DeepValidateTypeAliasResult ? true : false;
//   FALSE

// Finally, the weirdness stops when using an object that's only two levels deep instead of three. Is it running into some recursion limit but not warning me?
const twoLevelsDeep = {
  topLevel: {
    myTuple: [{ tup1: null }]
  }
} as const;
type AWorkingAsConstTypeCheck = DeepValidateShapev2WithBooleanResult<DeepSimplifyObject<DeepWriteable<typeof twoLevelsDeep>>, {
//  FALSE
  topLevel: {
    myTuple: [{ tup1: null, extra: '' }]
  }
}>;
0

There are 0 answers