5

How do I safely convert a Typescript object to Record<PropertyKey, unknown>? For example, when reading JSON you get an any object which should really be unknown (I assume it isn't for backwards compatibility):

const ob = JSON.parse("{}") as unknown;

I can convert the unknown to an object using a type assertion:

if (typeof ob !== "object" || ob === null) {
  throw new Error("Not an object");
}
// Typescript now infers the type of ob as `object`

But what check do I now do to convince Typescript that it is safe to treat it as Record<PropertyKey, unknown>? Is it possible that there are objects that aren't Records?

I'm sure it has to be said, but I am not looking for ob as Record<PropertyKey, unknown>.

Timmmm
  • 88,195
  • 71
  • 364
  • 509

2 Answers2

2

Thanks to @Jack Wilson, this works, though extending the Object interface does seem a little icky.

interface Object {
    [index: string]: unknown; // You can't do `[index: PropertyKey]` or `[P in PropertyKey]` for some reason.
    [index: number]: unknown;
}

const ob = JSON.parse("{}") as unknown;

if (!(ob instanceof Object)) {
  throw new Error("Not an object");
}

const a = ob["foo"]; // a is unknown.
Timmmm
  • 88,195
  • 71
  • 364
  • 509
1

Similar to io-ts mentioned in comments, check out zod for your use case: https://github.com/vriad/zod/

import * as z from 'zod';

// I believe this kind of circular reference requires ts 3.7+
type JsonValue =
  | { [index: string]: JsonValue }
  | JsonValue[]
  | boolean
  | null
  | number
  | string;

type JsonObject = { [index: string]: JsonValue } | JsonValue[];

const valueSchema: z.ZodSchema<JsonValue> = z.lazy(() => {
  return z.union([
    z.record(valueSchema),
    z.array(valueSchema),
    z.boolean(),
    z.null(),
    z.number(),
    z.string(),
  ]);
});

const objectSchema: z.ZodSchema<JsonObject> = z.lazy(() => {
  return z.union([z.record(valueSchema), z.array(valueSchema)]);
});

// results in a correctly typed object, or else throws an error
objectSchema.parse(JSON.parse('whatever unknown string'));
  • Useful information, though I'm still interested in an answer to the original question. – Timmmm Sep 29 '20 at 16:43
  • An instance of Date is an object but is not a Record, for example. I can't think of anything short of a type guard for assuring tsc that the value output by JSON.parse will be a record. – Jack Wilson Oct 01 '20 at 11:58
  • A `Date` object is a `Record`. What makes you think it isn't? – Timmmm Oct 01 '20 at 15:34
  • Tried it and tsc wouldn't allow it: [eval].ts:1:7 - error TS2322: Type 'Date' is not assignable to type 'Record'. – Jack Wilson Oct 01 '20 at 17:49
  • You have to do `let a = new Date() as unknown as Record;`. The very point of this question is how do I convince Typescript that I don't need the casts? – Timmmm Oct 02 '20 at 08:44
  • Isn't the `as` type assertion simply disabling the check altogether? e,g, `let x = new Date() as unknown as string[];` and `let b: Boolean = 'hello' as unknown as boolean;` works, though they don't actually make sense. In the case of assigning a date to a Record, the specific error is `Index signature is missing in type 'Date'.`. I suppose it's not enough to have string/unknown pairs; date's type must define an index signature as well. So, I think you cannot convince tsc to accept this except by using the check-disabling assertions you are trying to avoid. – Jack Wilson Oct 02 '20 at 11:48
  • 1
    Maybe I didn't explain well enough: `Date` *does* satisfy the `Record` contract. In other words if I force (via `as`) Typescript to convert a `Date` to `Record`, then nothing I can do with the resulting record will result in a type error. Because that is the case, I *shouldn't need the `as` cast*. Does that make sense? – Timmmm Oct 02 '20 at 12:02
  • 1
    Now I see what you mean. In that case, you can eliminate the type error by adding your own index signature to its interface: `interface Date { [index: PropertyKey]: unknown; }`. Interfaces merge, so you won't mangle the existing one. – Jack Wilson Oct 02 '20 at 13:23
  • 1
    Ah that does actually work! I couldn't change `object` because it is a keyword, but I was able to extend `Object`. – Timmmm Oct 02 '20 at 14:06