Branded types as object keys are not safe to use

2 weeks ago 21
ARTICLE AD BOX

I realized today that branded types used as record (or Map) keys are not safe to use if you fail to pass the exact record shape.

Here's an example:

type KeyA = string & { readonly __branded__: unique symbol } type KeyB = string & { readonly __branded__: unique symbol } type Stats = { count: number }; type ObjA = Record<KeyA, Stats>; type ObjB = Record<KeyB, Record<KeyA, Stats>>; const a: ObjA = {} as any as ObjA; const b: ObjB = {} as any as ObjB; const c: ObjB = a; // no error

Playground

The same happens with Zod branded types:

import * as z from "zod"; const keyASchema = z.string().brand<"keyA">(); const keyBSchema = z.string().brand<"keyB">(); type KeyA = z.infer<typeof keyASchema>; type KeyB = z.infer<typeof keyBSchema>; const statsSchema = z.object({ count: z.number(), }); const objA = z.record(keyASchema, statsSchema); const objB = z.record(keyASchema, z.record(keyBSchema, statsSchema)); type ObjA = z.infer<typeof objA>; type ObjB = z.infer<typeof objB>; const a: ObjA = {} as any as ObjA; const b: ObjB = {} as any as ObjB; const c: ObjB = a; // no error

This does not happen with a regular string as key:

type KeyA = string type KeyB = string type Stats = { count: number }; type ObjA = Record<KeyA, Stats>; type ObjB = Record<KeyB, Record<KeyA, Stats>>; const a: ObjA = {} as any as ObjA; const b: ObjB = {} as any as ObjB; const c: ObjB = a; // fails

What I think happens is that Typescript assumes that KeyA and KeyB are mutually exclusive since different types, so a code handling a ObjB will never try a KeyA. So if you give a ObjA no error will happen.

That's true, but I'd ideally have typescript giving me an error like: Expected a Record<KeyA, Record<KeyB, Stats>> and got Record<KeyA, Stats> instead

Any advice?

Read Entire Article