ashik@dev

Narrowing Unknown Without Lying to the Compiler

Last week I wrote a tiny helper at work, and I've already used it more than I expected. It's about twenty lines of runtime code and a handful of type-level tricks. It started as a way to stop writing the same ugly checks inside catch blocks. It didn't stay there for long, because the exact same problem shows up every time a response comes back from an API I don't control.

This post builds the helper up from scratch, then looks at the bigger problem of third-party responses that come with no schema at all. And because a helper like this is only worth trusting once you know where it breaks, it also goes through the places where it does.

The problem: catch gives you unknown

With strict on, TypeScript types the variable in a catch block as unknown. That's the correct call, since anything can be thrown, but it makes this very common code a compile error:

ts
try {
  await db.insert(users).values(newUser)
} catch (error) {
  if (error.code === "23505") {
    // TS18046: 'error' is of type 'unknown'.
    throw new ConflictException("Email already registered")
  }
  throw error
}

23505 is Postgres telling you a unique constraint was violated. I want to check it, and I want to read error.constraint to see which constraint. The compiler, reasonably, wants proof those properties exist first.

There are three usual ways out.

Cast it. (error as any).code or (error as PgError).code. It compiles, but you haven't proven anything. You've just told the compiler to stop asking.

Check it by hand. This is correct, and TypeScript narrows it properly:

ts
if (
  typeof error === "object" &&
  error !== null &&
  "code" in error &&
  typeof error.code === "string"
) {
  // error.code is a string here
}

That's four conditions for one property. Now try it for an HTTP client error where the message is at error.response.data.message. You write the whole dance again for response, then for data, then for message. It works, and nobody wants to read it.

Use a schema library. Zod can describe the shape and check it. But writing a full z.object for every little "does this error have a code?" question is heavy, and a plain safeParse doesn't narrow the original variable anyway.

What I wanted was one short call that does the runtime check and narrows the type, driven by the same input.

Step 1: a Zod-backed type guard

The smallest useful piece is a type guard that trusts Zod:

ts
const followsSchema = <T extends z.ZodTypeAny>(
  data: unknown,
  schema: T,
): data is z.infer<T> => schema.safeParse(data).success

If the schema accepts the value, TypeScript narrows data to whatever that schema describes. It's already better than casting, but you still have to write the nested z.object calls yourself.

Step 2: build the schema from a path

Here's the idea the whole helper rests on. A dotted path like "response.data.message" plus a schema for the final value contains everything needed to build the nested schema.

You walk the keys from the inside out:

ts
const schemaForPath = (path: string, leaf: z.ZodTypeAny): z.ZodTypeAny =>
  path
    .split(".")
    .reverse()
    .reduce<z.ZodTypeAny>((inner, key) => z.object({ [key]: inner }), leaf)

For "response.data.message" with z.string(), the reduce builds this:

ts
z.object({
  response: z.object({
    data: z.object({
      message: z.string(),
    }),
  }),
})

z.object ignores extra keys by default, so a real error object with twenty other properties still passes. We only care about the path we asked for.

Step 3: teach the type system the same trick

The runtime side now understands dotted paths. For narrowing to work, the type side has to understand them too, and TypeScript's template literal types can do exactly that.

First, split the string into a tuple of keys:

ts
type Split<S extends string> = S extends `${infer Head}.${infer Tail}`
  ? [Head, ...Split<Tail>]
  : [S]

// Split<"response.data.message"> = ["response", "data", "message"]

Then turn that tuple back into a nested object type, with the schema's type at the bottom:

ts
type NestedObject<Keys extends readonly string[], Leaf> = Keys extends [
  infer K extends string,
  ...infer Rest extends string[],
]
  ? { [P in K]: NestedObject<Rest, Leaf> }
  : Leaf

type PathToObject<Path extends string, Schema extends z.ZodTypeAny> = NestedObject<
  Split<Path>,
  z.infer<Schema>
>

// PathToObject<"response.data.message", z.ZodString>
//   = { response: { data: { message: string } } }

It's the same recursion as the runtime reduce, just done by the compiler. The runtime builds a schema from the path, the type system builds a type from the path, and both read the same string.

Putting it together

The first version I wrote looked like this:

ts
export const hasProperty = <
  Path extends string,
  Schema extends z.ZodTypeAny = z.ZodUnknown,
>(
  obj: unknown,
  path: Path,
  leafSchema?: Schema,
): obj is PathToObject<Path, Schema> =>
  schemaForPath(path, leafSchema ?? z.unknown()).safeParse(obj).success

And the catch block from the start of the post becomes:

ts
if (
  hasProperty(error, "code", z.string()) &&
  hasProperty(error, "constraint", z.string()) &&
  error.code === "23505"
) {
  if (error.constraint.includes("email")) {
    throw new ConflictException("Email already registered")
  }
}

Both checks narrow, and TypeScript combines them, so after the second call error has both code and constraint as strings.

The nested HTTP case is one line:

ts
if (hasProperty(error, "response.data.message", z.string())) {
  return error.response.data.message
}
return "Something went wrong"

An asserting version

Sometimes you don't want to branch. If the value is wrong, you want to stop right there with a useful error. A decoded token payload is a good example, since it comes back typed as unknown and should never be malformed.

TypeScript's asserts return type handles that:

ts
assertProperty(payload, "callbackUrl", z.string().url(), "Invalid state")
// from here on, payload.callbackUrl is a string
redirect(payload.callbackUrl)

If you don't pass a message, it falls back to zod-validation-error, which turns Zod's error into something readable:

text
Validation error: Expected string, received number at "a.b"

Autocomplete when the type is already known

A few days after the first version, I wanted the helper to also work on values that already have a type, and to suggest paths while I type. That meant two changes.

First, a type that lists every dotted path in an object:

ts
type ObjectPaths<T> = T extends object
  ? {
      [K in keyof T]: K extends string
        ? T[K] extends object
          ? K | `${K}.${ObjectPaths<T[K]>}`
          : K
        : never
    }[keyof T]
  : never

Second, a slightly odd-looking parameter type:

ts
path: (Path & {}) | ObjectPaths<Obj>

ObjectPaths<Obj> gives the editor a list of known paths to suggest. Path & {} still accepts any string, which is what you want when the input is unknown and there's nothing to suggest. The & {} is a well-known trick. Without it, TypeScript merges the literal suggestions into plain string, and autocomplete disappears.

Then I hit a wall. Once obj became a generic Obj instead of unknown, the compiler rejected the return type:

text
TS2677: A type predicate's type must be assignable to its parameter's type.

TypeScript is right. { code: string } isn't necessarily assignable to some arbitrary Obj. In the version I committed, I silenced it with a // @ts-expect-error. It works, but it's the kind of line that makes me uneasy every time I scroll past it.

While writing this post I found the proper fix, and it's embarrassingly small. Intersect with the original type:

ts
): obj is Obj & PathToObject<Path, Schema>

An intersection is always assignable to both of its parts, so the error goes away without any suppression. It also narrows better. Given { theme: { mode: string }; version: number } and a check for "theme.mode" with z.literal("dark"), s.theme.mode becomes "dark" and s.version is still a number.

Where it breaks

I tested the helper against a bunch of edge cases, and four of them are worth knowing about.

1. z.unknown() doesn't mean "exists"

The default leaf schema was z.unknown(), and z.unknown() accepts undefined. Inside z.object, a missing key reads as undefined, so:

ts
hasProperty({}, "state") // true in the first version

That's the opposite of what "has property" suggests. If you wrote if (!hasProperty(payload, "state")) throw ..., that throw would never happen.

The fix is a default leaf that rejects undefined:

ts
const present = z.unknown().refine((value) => value !== undefined, "Required")

Now {} fails, while { state: null } and { state: "x" } pass. Treating null as present is deliberate: the key is there, it's just empty.

2. Refinements can make the else branch lie

This one is subtle and belongs to type guards in general, not just this helper.

A type guard makes two promises. In the true branch, the value matches. In the false branch, the value doesn't match the predicate type. The trouble is that z.string().min(5) still has the type string. The refinement exists at runtime and not in the type.

ts
function check(v: { code: string }) {
  if (!hasProperty(v, "code", z.string().min(5))) {
    // At runtime, v might be { code: "abc" }.
    // TypeScript thinks v is `never` here.
  }
}

The runtime says "no, the code is too short". TypeScript hears "no, this isn't { code: string }", decides that's impossible for a value typed { code: string }, and narrows v to never.

With unknown inputs, which is the main use case, this doesn't come up, because there's nothing to subtract from. My rule now is: refinements are fine on unknown. On values that are already typed, use only plain type schemas in hasProperty, or use assertProperty, which has no else branch to get wrong.

3. Paths are object keys only

"items.0.name" returns false, because z.object rejects arrays. Keys that contain a dot, like { "a.b": 1 }, can't be reached either, since the path gets split on the dot. For arrays, check the array itself with a schema like z.array(...).

4. It builds schemas on every call

Every call creates a few small Zod objects. For error handling and request payloads, that cost is nothing. I wouldn't put it inside a loop over a hundred thousand rows.

Beyond catch: responses from APIs you don't control

catch blocks are where I first noticed this, but they're the small version of the problem. The bigger one is every response body from an API someone else owns.

The default types are convenient, not true

ts
const res = await fetch("https://api.example.com/users/1")
const user = await res.json()

user.literally.anything // compiles

res.json() returns Promise<any>. That's worse than unknown. unknown makes you prove things. any quietly turns checking off for the value and for everything you assign it to.

Axios looks safer:

ts
const { data } = await axios.get<User>("https://api.example.com/users/1")
data.name // string, apparently

Nothing checks that <User>. It's a cast dressed up as a generic. If the API actually returns { "full_name": "..." }, then data.name is undefined at runtime while TypeScript insists it's a string. The crash shows up later, far away from the request, which is the worst possible place to debug it.

Step zero: say unknown out loud

ts
const body: unknown = await res.json()

It's one annotation, and now every line that reads the body has to justify itself. A surprising amount of the value comes from this line alone.

No schema means your type is a guess

If the provider publishes an OpenAPI spec, generate types from it. That's the best case. But plenty of third-party APIs don't have one, or have one that's months behind the real thing. The "schema" is a documentation page with some example JSON.

In that situation, any type you write is just your reading of the docs. It might be right. The only way to find out is to check real responses at runtime, so the type and the check should be the same thing. That's exactly what a Zod schema is.

Parse once, at the edge

The pattern I've settled on: every third-party call goes through one small function that fetches, validates and returns a properly typed value. Nothing past that function ever sees unknown or any.

ts
const ForecastResponse = z.object({
  location: z.object({ name: z.string() }),
  current: z.object({
    temp_c: z.number(),
    condition: z.object({ text: z.string() }),
  }),
})

export type Forecast = z.infer<typeof ForecastResponse>

export async function getForecast(city: string): Promise<Forecast> {
  const res = await fetch(
    `https://api.example.com/forecast?q=${encodeURIComponent(city)}`,
  )
  const body: unknown = await res.json().catch(() => null)

  if (!res.ok) {
    const reason = hasProperty(body, "error.message", z.string())
      ? body.error.message
      : res.statusText
    throw new Error(`Forecast request failed (${res.status}): ${reason}`)
  }

  const result = ForecastResponse.safeParse(body)
  if (!result.success) {
    throw new Error(`Unexpected forecast response: ${fromZodError(result.error).message}`)
  }
  return result.data
}

A few details in there are deliberate.

  • The schema only lists what I use. The real response probably has fifty fields. I describe the four I read. By default, z.object also strips unknown keys from the parsed result, so the rest of the app can't start depending on a field nobody validated.
  • Extra fields are fine. Providers add fields all the time, and that shouldn't break anything. That's why I don't use .strict() on third-party responses. A missing field or a field with the wrong type should break things, loudly, right here.
  • The error body gets a spot check, not a schema. When a request fails, all I want is a message if there is one. That's the job hasProperty is good at: one path, one type, and a fallback when it isn't there.
  • Failures say exactly what went wrong. Instead of Cannot read properties of undefined three components later, you get Unexpected forecast response: Validation error: Expected number, received string at "current.temp_c". That one line tells you the API changed, which field changed, and how.

Full schema or hasProperty?

My rough rule:

  • If I'm going to use the data, meaning pass it around, store it or render it, it gets a full schema at the boundary.
  • If I only need one or two fields to make a decision, hasProperty or assertProperty is enough.

Webhooks sit somewhere in between. Payment providers usually give you nicely typed events, but the metadata you attached yourself comes back as a plain bag of strings. That's exactly where assertProperty earns its keep:

ts
assertProperty(metadata, "userId", z.string().min(1), "Checkout is missing userId metadata")
assertProperty(metadata, "plan", z.enum(["monthly", "yearly"]), "Checkout has an unknown plan")

// metadata.plan is now "monthly" | "yearly", not just string

Verify the webhook signature before any of this. Validation tells you the shape is right. It doesn't tell you the sender is who they say they are.

Things third-party APIs will do to you

  • Send numbers as strings. Prices like "12.50" are common. z.coerce.number() is tempting, but it turns an empty string into 0, which is a quiet way to give something away for free. Be explicit instead: z.string().regex(/^\d+(\.\d+)?$/).transform(Number).
  • Leave out fields the docs say are always there. Documentation describes the happy path. When the docs and the real responses disagree, the responses win. Save a few real payloads, with tokens and personal data removed, as test fixtures, and run your schema against them.
  • Wrap the data you need in arrays. As mentioned above, hasProperty can't reach into arrays. Anything list-shaped belongs in a full schema with z.array.
  • Fail in production at 3 a.m. When parsing fails, you'll want the raw body to see what changed. Log it, but scrub secrets and personal data first.

The final version

Here's the whole thing with the fixes above. It compiles under strict with no suppression comments:

ts
import { z, type ZodTypeAny } from "zod"
import { fromZodError } from "zod-validation-error"

type Split<S extends string> = S extends `${infer Head}.${infer Tail}`
  ? [Head, ...Split<Tail>]
  : [S]

type NestedObject<Keys extends readonly string[], Leaf> = Keys extends [
  infer K extends string,
  ...infer Rest extends string[],
]
  ? { [P in K]: NestedObject<Rest, Leaf> }
  : Leaf

type PathToObject<Path extends string, Schema extends ZodTypeAny> = NestedObject<
  Split<Path>,
  z.infer<Schema>
>

type ObjectPaths<T> = T extends object
  ? {
      [K in keyof T]: K extends string
        ? T[K] extends object
          ? K | `${K}.${ObjectPaths<T[K]>}`
          : K
        : never
    }[keyof T]
  : never

const present = z.unknown().refine((value) => value !== undefined, "Required")

const schemaForPath = (path: string, leaf: ZodTypeAny = present): ZodTypeAny =>
  path
    .split(".")
    .reverse()
    .reduce<ZodTypeAny>((inner, key) => z.object({ [key]: inner }), leaf)

export const hasProperty = <
  Obj,
  Path extends string,
  Schema extends ZodTypeAny = z.ZodUnknown,
>(
  obj: Obj,
  path: (Path & {}) | ObjectPaths<Obj>,
  leafSchema?: Schema,
): obj is Obj & PathToObject<Path, Schema> =>
  schemaForPath(path, leafSchema).safeParse(obj).success

export function assertProperty<
  Obj,
  Path extends string,
  Schema extends ZodTypeAny = z.ZodUnknown,
>(
  obj: Obj,
  path: (Path & {}) | ObjectPaths<Obj>,
  leafSchema?: Schema,
  message?: string,
): asserts obj is Obj & PathToObject<Path, Schema> {
  const result = schemaForPath(path, leafSchema).safeParse(obj)
  if (!result.success) {
    throw new Error(message ?? fromZodError(result.error).toString())
  }
}

Why I like it

The part I enjoy most isn't the template literal types, although writing Split does feel a bit like showing off.

It's that the runtime check and the compile-time type come from the same string. With a manual check, you write the runtime condition, and the narrowing follows from it only if you wrote the condition exactly right. With a cast, the type and the runtime aren't connected at all. Here, "response.data.message" builds the schema and the type. If one is right, so is the other.

It also changed where I put checks. Anything that enters my code from outside, whether it's a thrown error, a response body or a webhook, gets proven once, at the edge. After that, the types can be trusted, because something actually checked them.

That's the kind of helper I trust. And going looking for where it breaks, instead of just enjoying that it works, turned up two real improvements to a function I thought was finished last week.

found a typo? posts live in git.suggest an edit ->

comments

view on github ->