question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Specify valid keys in Record

See original GitHub issue

In TS, I can define an object like so:

type Flags = Record<"flag1"|"flag2", boolean>

or:

type Flags = {[k in "flag1"|"flag2"]: boolean}

It doesn’t seem there’s any way to accomplish this now in zod? I have lots of TS types using this pattern with large unions that I really don’t want to duplicate everywhere as z.object keys, so this would be handy to get full parity with TS.

Issue Analytics

  • State:closed
  • Created 3 years ago
  • Reactions:4
  • Comments:10 (2 by maintainers)

github_iconTop GitHub Comments

4reactions
danenaniacommented, May 28, 2020

Found a seemingly workable solution to this:

const ZodLiteralRecord = <
  KeyType extends string,
  ZodValueType extends z.ZodTypeAny
>(
  keys: KeyType[],
  zodValueType: ZodValueType
) =>
  z.object(
    keys.reduce(
      (agg, k) => ({
        ...agg,
        [k]: zodValueType.optional(),
      }),
      {} as Record<KeyType, z.ZodUnion<[ZodValueType, z.ZodUndefined]>>
    )
  );

Can be used like so:

const FlagsSchema = ZodLiteralRecord([<const>"flag1", <const>"flag2"], z.literal(true));
type Flags = z.infer<typeof FlagsSchema>;  // {flag1?: true | undefined, flag2?: true | undefined}

Perhaps worth adding to the core?

1reaction
low-ghostcommented, Jan 29, 2022

I was surprised to see that records with literals and zod enums work perfectly fine, as tests in this general area show https://github.com/colinhacks/zod/blob/cc8ad1981ba580d1250520fde8878073d4b7d40a/src/__tests__/record.test.ts#L10-L17 so perhaps the bug directly above my comment is solved?

There’s a couple of interesting things though. Records in ts enforce that all of their keys are present. e.g.:

enum Something {
  F1 = "f1",
  F2 = "f2",
}
type SomethingNums = Record<Something, number>;
const f1: AssertEqual<SomethingNums, { f1: number; f2: number }> = true;

Note that the number fields are required and not { f1?: number; f2?: number }. So the approach described again in @akomm’s comment is not equivalent even in terms of the actual record parsing. So

const SomethingNumsZ = z.record(z.union([z.literal("f1"), z.literal("f2")]), z.number());
expect(() => SomethingNumsZ.parse({ f1: 1 })).to.throw("should throw due to missing f2");

fails to throw. That goes against my expectation at least. Same could be said of @danenania’s solution, where the fields are explicitly and intentionally made optional.

In that line, I like this as a util:

export const RecordOf = <
  T extends Record<string, string>,
  ZodValueType extends z.ZodTypeAny
>(
  obj: T,
  zodValueType: ZodValueType
) => {
  type KeyType = T[keyof T];
  const keys = Object.values(obj);
  return z.object(
    keys.reduce(
      (agg, k) => ({
        ...agg,
        [k]: zodValueType,
      }),
      {} as Record<KeyType, ZodValueType>
    )
  );
}

and some tests to prove that RecordOf functions very similarly to ts’s Record:

describe("RecordOf", () => {
  enum Ordinals {
    FIRST = "first",
    SECOND = "second",
    THIRD = "third"
  };

  const PlanetZ = z.object({
    name: z.string(),
    moons: z.number(),
  });

  // Well, at least the start of one
  const SolarSystemZ = RecordOf(Ordinals, PlanetZ);
  type SolarSystem = z.infer<typeof SolarSystemZ>;
  // The equivalent in pure ts. could infer from PlanetZ, but being explict/safe here
  type NativeSolarSystem = Record<Ordinals, { name: string; moons: number; }>;

  const testObj = {
    first: { name: "Mercury", moons: 0 },
    second: { name: "Venus", moons: 0 },
    third: { name: "Earth", moons: 1 },
  };

  it("should make a zod record type with provided keys and value", () => {
    // borrowed from zod's tests
    type AssertEqual<T, Expected> = [T] extends [Expected]
      ? [Expected] extends [T]
      ? true
      : false
      : false;
    const f1_: AssertEqual<NativeSolarSystem, SolarSystem> = true;
    // @ts-ignore _def doesn't have typeName on its type
    expect(SolarSystemZ._def.typeName).to.equal(
      "ZodObject",
      "should not be a ZodRecord, unfortunately, because we want to require all keys" 
    );
  });

  it("should parse a correct object", () => {
    expect(SolarSystemZ.parse(testObj)).to.deep.equal(testObj);
  });

  it("should throw on bad check", () => {
    // pull off 'first' to fail check. Note that this means the result requires all
    // enum fields to be present. use `.optional()` if that isn't intended
    const { first: _, ...restObj } = testObj;
    expect(() => SolarSystemZ.parse(restObj)).to.throw();
  });
});

as the last test says, could always SolarSystemZ.optional() to get the alternative behavior

Read more comments on GitHub >

github_iconTop Results From Across the Web

Specify valid keys in Record · Issue #55 · colinhacks/zod
I often use record types with unions that define required keys, for objects in which the value types are the same throughout. It's...
Read more >
Define a list of optional keys for Typescript Record
I want to type an object which can only have keys 'a', 'b' or 'c' ...
Read more >
Valid Keys for a Record or File - IBM
The key for a file is determined by the valid keys for the record types in that file. The file's key is determined...
Read more >
TypeScript | Record Utility Type - [2022 Guide] - Daily Dev Tips
By doing this, we ensure that only valid keys can be passed. Let's say we have a type of admin user (a weird...
Read more >
TypeScript's Record Type Explained | by Sunny Sun
At face value, it says the Record type creates an object type that has properties of type Keys with corresponding values of type...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found