Typescript

Validating Multi-Provider Credentials with Zod Discriminated Unions

How to use Zod's discriminatedUnion to validate per-platform credentials, with a runtime-injected discriminant and TypeScript narrowing at call sites.

When you build an app that connects to multiple external platforms, each platform usually has its own credential shape. One platform might use OAuth 1.0a with four fields; another uses OAuth 2.0 with an access token, optional refresh token, and expiry date. Storing all of them in the same encrypted JSON column is convenient, but validating the blob on the way out requires knowing which schema to apply.

The naive approach is a pile of if statements followed by a type assertion. The better one is z.discriminatedUnion.

The starting point

Say you have a table of connected accounts. Each row stores credentials as an encrypted JSON blob. The blob shape differs by platform, but the row has a platform string field that identifies it.

If you only support one platform at first, the validation is straightforward:

export const xCredentialsSchema = z.object({
    consumerKey: z.string().min(1),
    consumerSecret: z.string().min(1),
    accessToken: z.string().min(1),
    accessTokenSecret: z.string().min(1),
})

export function loadAccountCredentials(account: AccountWithCredentials): XCredentials {
    const parsed = JSON.parse(decrypt(account.credentials!))
    const result = xCredentialsSchema.safeParse(parsed)
    if (!result.success) throw new InvalidCredentialsError(account.id, result.error.message)
    return result.data
}

This works until you add a second platform. The return type XCredentials is wrong for any non-X account, and callers have no way to tell which shape they received.

Building the discriminated union

z.discriminatedUnion takes a discriminant key and an array of schemas. Each member extends that key with a unique literal. TypeScript narrows the resulting union whenever you check the discriminant:

export const xCredentialsSchema = z.object({
    consumerKey: z.string().min(1),
    consumerSecret: z.string().min(1),
    accessToken: z.string().min(1),
    accessTokenSecret: z.string().min(1),
})

export const linkedinCredentialsSchema = z.object({
    accessToken: z.string().min(1),
    refreshToken: z.string().optional(),
    expiresAt: z.string().datetime().optional(),
})

export const accountCredentialsSchema = z.discriminatedUnion('platform', [
    xCredentialsSchema.extend({ platform: z.literal('x') }),
    linkedinCredentialsSchema.extend({ platform: z.literal('linkedin') }),
])

export type AccountCredentials = z.infer<typeof accountCredentialsSchema>

AccountCredentials now resolves to:

| { platform: 'x'; consumerKey: string; consumerSecret: string; accessToken: string; accessTokenSecret: string }
| { platform: 'linkedin'; accessToken: string; refreshToken?: string; expiresAt?: string }

.extend({ platform: z.literal('x') }) adds the discriminant to each base schema without repeating field definitions. If the base schema changes, the union member changes with it.

Injecting the discriminant at runtime

The platform value should not be stored inside the encrypted blob. It already exists on the account row in the database. Storing it in the blob too would duplicate data, and it would mean trusting a value inside a blob to determine which schema validates that same blob.

The right approach is to inject platform from the account record just before validation:

export function loadAccountCredentials(account: AccountWithCredentials): AccountCredentials {
    if (!account.credentials) throw new MissingCredentialsError(account.id)

    let parsed: unknown
    try {
        parsed = JSON.parse(decrypt(account.credentials))
    } catch (err) {
        throw new InvalidCredentialsError(account.id, err)
    }

    // Spread parsed first so account.platform always wins over any stale
    // "platform" key that might exist inside the blob.
    const withPlatform =
        typeof parsed === 'object' && parsed !== null
            ? { ...parsed, platform: account.platform }
            : parsed

    const result = accountCredentialsSchema.safeParse(withPlatform)
    if (!result.success) throw new InvalidCredentialsError(account.id, result.error.message)
    return result.data
}

The spread order matters. { ...parsed, platform: account.platform } puts the database value last so it overrides anything with the same key in the blob. Credential blobs tend to be long-lived and can predate schema changes or key renames. The canonical source (the DB row) should always win.

Narrowing at call sites

Once loadAccountCredentials returns AccountCredentials, callers narrow on platform and TypeScript exposes the right fields:

const credentials = loadAccountCredentials(account)

if (credentials.platform !== 'x') return  // skip non-X accounts

// TypeScript now knows credentials.consumerKey exists
const result = await xService.post(credentials, content)

The old approach required either casting (credentials as XCredentials) or separately checking account.platform before accessing credential fields. The union ties the platform identity and its credential shape into one type, so narrowing on platform also unlocks the matching fields.

discriminatedUnion vs union

Zod's z.union checks each variant in order until one passes. On invalid input, the error is a merge of all variant errors, which is verbose and often misleading.

z.discriminatedUnion looks up the discriminant key first, then validates only the matching variant. The error messages are specific, performance is better on larger unions, and TypeScript inference works the same way on both. Unless the discriminant is optional or structurally complex, discriminatedUnion is the right choice.

Adding a third platform

Adding another platform does not touch existing code. The steps are:

  1. Define its schema
  2. Add it to the discriminatedUnion array
  3. Add the platform value to your database enum
  4. Add a if (credentials.platform !== 'x') return guard anywhere that only handles a subset of platforms

loadAccountCredentials stays the same. Existing call sites that already branch on platform stay the same. The union grows; the glue code does not.

Conclusion

Storing the discriminant in the database row rather than the encrypted blob keeps a single source of truth and avoids circular validation. Injecting it at the moment you call safeParse is a small change to loadAccountCredentials that gives you typed, narrowable credentials throughout the rest of the codebase. Adding a platform later is a schema change, not a refactor.

Newer →
Using Service Interfaces and Null Objects for Resilient TypeScript Scripts

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.