Handle Dynamic Values in TypeScript

Learn how to safely type dynamic values from API responses, JSON parsing, user input, and third-party code. Use unknown, type guards, and runtime validation.

6 min read

TypeScript dynamic values are values whose shape is not known until the program runs: API responses, JSON.parse results, form inputs, environment variables, local storage, and third-party libraries. TypeScript cannot know their shape at compile time, so you must handle them safely at runtime.

The wrong approach is to use any and hope for the best. The right approach is to use unknown, narrow it with checks, and validate critical data.

Use unknown Instead of any

The unknown type is the type-safe alternative to any. While any lets you access any property without complaint, unknown forces you to prove the type before you use it:

typescripttypescript
function handleDynamicValue(value: unknown) {
  console.log(value.toUpperCase());
}

Calling a string method directly on an unknown value fails to compile, since the compiler has no evidence the value actually is a string:

texttext
Object is of type 'unknown'.

TypeScript refuses to let you call a string method on an unknown value because it might not be a string. Fix it by narrowing:

typescripttypescript
function handleDynamicValue(value: unknown) {
  if (typeof value === "string") {
    console.log(value.toUpperCase());
  }
}

For more on the difference, see Any vs Unknown in TypeScript.

Narrow With typeof and Array.isArray

The typeof operator is the simplest way to check a dynamic value's type at runtime. TypeScript understands these checks as type guards:

typescripttypescript
function processValue(value: unknown): string {
  if (typeof value === "string") {
    return value.trim();
  }
  if (typeof value === "number") {
    return value.toFixed(2);
  }
  if (Array.isArray(value)) {
    return value.join(", ");
  }
  return String(value);
}

After each typeof check, TypeScript narrows the value to the specific type inside that branch. You can safely call string methods, number methods, and array methods.

typeof handles the primitives you would expect, such as string, number, boolean, and undefined. Two quirks are worth remembering: typeof null returns "object", and typeof for an array also returns "object" instead of a distinct array label. Use Array.isArray when you specifically need to detect an array.

Narrow Object Shapes With the in Operator

For objects with known property names, use the in operator to check for the presence of a key:

typescripttypescript
type User = { name: string; email: string };
type Product = { title: string; price: number };
 
function describe(item: User | Product): string {
  if ("name" in item) {
    return `User: ${item.name}`;
  }
  return `Product: ${item.title}, $${item.price}`;
}

TypeScript narrows the item parameter to User when the name check is true, because Product does not have a name property at all.

This pattern works well with discriminated unions. If each variant has a unique kind or type property, the check is even cleaner:

typescripttypescript
type ApiResponse =
  | { status: "success"; data: unknown }
  | { status: "error"; message: string };
 
function handleResponse(response: ApiResponse): void {
  if (response.status === "success") {
    console.log("Got data:", response.data);
  } else {
    console.error("Error:", response.message);
  }
}

This pattern is called a discriminated union: a common literal field, like the status property here, lets TypeScript distinguish between variants without any extra runtime checks.

Type JSON.parse Results

JSON.parse returns the any type by default, which gives you no type safety. Declare the expected type explicitly:

typescripttypescript
interface Config {
  port: number;
  host: string;
}
 
const rawConfig = '{"port": 3000, "host": "localhost"}';
const config: Config = JSON.parse(rawConfig);
 
console.log(config.port);

The Config annotation tells TypeScript what shape you expect, but this is a compile-time assertion only.

If the JSON string is missing a field or contains extra fields, TypeScript will not catch it. The annotation trusts you.

For safety at the boundary, check the shape at runtime before trusting it. A small helper function keeps that check reusable:

typescripttypescript
function isConfig(value: unknown): value is Config {
  if (typeof value !== "object" || value === null) return false;
  const record = value as Record<string, unknown>;
  return typeof record.port === "number" && typeof record.host === "string";
}

The value is Type return type marks this as a type guard, which the next section covers in more detail. Once the shape is confirmed, parsing the raw string becomes a straightforward check-then-return:

typescripttypescript
function parseConfig(raw: string): Config {
  const parsed: unknown = JSON.parse(raw);
  if (!isConfig(parsed)) {
    throw new Error("Invalid config format");
  }
  return parsed;
}

This approach verifies the shape at runtime before returning the typed value. For larger objects, a validation library like Zod is more maintainable. See Use Zod with TypeScript.

Create Custom Type Guards

When built-in checks are not enough, write a custom type guard. A type guard is a function whose return type uses the same value-is-Type syntax shown above:

typescripttypescript
interface Cat {
  meow(): void;
}
 
interface Dog {
  bark(): void;
}
 
function isCat(animal: Cat | Dog): animal is Cat {
  return "meow" in animal;
}

The animal-is-Cat return type tells TypeScript that when isCat returns true, the value is a Cat. Calling the guard inside an if check narrows the union on both branches:

typescripttypescript
function handleAnimal(animal: Cat | Dog): void {
  if (isCat(animal)) {
    animal.meow();
  } else {
    animal.bark();
  }
}

For more patterns, see Custom Type Guards in TypeScript.

Handle Dynamic Property Access

JavaScript code often accesses object properties dynamically with bracket notation. TypeScript needs help understanding the value type:

typescripttypescript
const settings: Record<string, unknown> = {
  theme: "dark",
  fontSize: 14,
  notifications: true,
};
 
function getSetting(key: string): unknown {
  return settings[key];
}
 
const theme = getSetting("theme");

The theme variable is unknown here because Record<string, unknown> tells TypeScript that values could be anything at any key. Narrow it the same way you would narrow any other unknown value before using it:

typescripttypescript
const themeValue = getSetting("theme");
if (typeof themeValue === "string") {
  console.log(themeValue.toUpperCase());
}

If you know the exact type for each key, use a more specific type instead. But for truly dynamic key-value stores, like local storage or query parameters, unknown is the honest starting point.

Handle Dynamic Values in Migration

During a JavaScript-to-TypeScript migration, you will encounter places where the code relies on dynamic behavior that is hard to type:

javascriptjavascript
// Legacy JS: merging configs from multiple sources
function buildConfig(envDefaults, fileConfig, cliArgs) {
  return Object.assign({}, envDefaults, fileConfig, cliArgs);
}

Rather than reaching for any on each parameter, start with Record<string, unknown> for every input object. It keeps the merge honest about not knowing the exact shape yet, while still ruling out primitives like strings or numbers:

typescripttypescript
function buildConfig(
  envDefaults: Record<string, unknown>,
  fileConfig: Record<string, unknown>,
  cliArgs: Record<string, unknown>
): Record<string, unknown> {
  return Object.assign({}, envDefaults, fileConfig, cliArgs);
}

This is safe and honest: the function accepts any objects and merges them. As you learn more about the expected shapes, replace the record type with specific interfaces. For details on replacing any systematically, see Replace any During TypeScript Migration.

Avoid These Patterns

Casting with as without checking. Writing JSON.parse(raw) as User lies to TypeScript if the JSON does not match. It is fine for quick prototypes but dangerous in production code.

Using any because unknown feels inconvenient. The inconvenience of unknown is the point. It forces you to think about what the value could be at runtime. Every unknown you narrow is a potential bug you prevented.

Over-narrowing with type predicates. Custom type guards should actually verify the value. Do not write a guard that always returns true just to satisfy the compiler.

Rune AI

Rune AI

Key Insights

  • Use unknown, not any, for values whose type is not known at compile time.
  • Narrow unknown with typeof, Array.isArray, and the in operator before accessing properties.
  • Declare expected types on JSON.parse and API responses with explicit annotations.
  • Custom type guards with value is Type return types tell TypeScript what a value is after a check.
  • For production data, use Zod or a similar runtime validation library that generates TypeScript types.
  • The discriminator pattern (a common 'type' or 'kind' field) is the cleanest way to handle different message shapes.
RunePowered by Rune AI

Frequently Asked Questions

Should I use any for values I cannot know at compile time?

No. Use unknown instead. unknown forces you to check the type before using the value, while any silently disables all type checking and can hide bugs.

How do I type the result of JSON.parse?

JSON.parse returns any by default. Declare the expected type explicitly: const data: User = JSON.parse(raw). Better yet, use a runtime validation library like Zod to verify the shape at runtime.

What is the quickest way to handle a dynamic value safely?

Use typeof checks for primitives, Array.isArray for arrays, and the in operator for object shapes. These are fast, built-in, and TypeScript understands them as type guards.

Conclusion

Dynamic values are the boundary between TypeScript's compile-time safety and the messy reality of runtime data. Use unknown as your default type for anything from outside the codebase. Narrow it with typeof checks, Array.isArray, and the in operator. For critical data, add runtime validation.