Skip to main content
  1. Posts/

The Type System Is a Programming Language

·3032 words·15 mins
Photograph By Towfiqu barbhuiya
Blog TypeScript Software Engineering
Table of Contents

The Function That Broke My Brain
#

Here’s a function signature that looks completely innocent:

function objectPick<T extends object>(obj: T, paths: string[]): ???

Pick some properties from an object using dot-notation paths. objectPick(user, ["name", "address.city"]) gives you { name: "Aaron", address: { city: "Toronto" } }. Simple enough at runtime — split on dots, walk the object, reconstruct the shape.

But what’s the return type?

Not Partial<T>. Not Record<string, unknown>. The exact type. If you pick "address.city" from a User, the return type should be { address: { city: string } } — not { address: { city: string; zip: number; country: string } }, and definitely not any. The compiler should know exactly what shape comes back, inferred from the string literals you pass in.

This is where TypeScript stops being “JavaScript with types” and starts being a programming language that happens to run at compile time.

Parsing Strings at Compile Time
#

The first problem: how do you teach the type system to understand "user.details.email" as a path through nested objects?

Template literal types with infer. Same concept as destructuring, but for type-level strings:

// Split a dot-path into head and rest
type Split<S extends string> = S extends `${infer Head}.${infer Rest}`
  ? { head: Head; rest: Rest }
  : { head: S; rest: never };

type Example = Split<"user.details.email">;
// { head: "user", rest: "details.email" }

TypeScript pattern-matches the string against the template, captures the parts before and after the first dot, and binds them to Head and Rest. If there’s no dot, Rest is never — you’ve hit a leaf.

This is infer doing real work. Not just extracting a return type from a function signature — parsing a string into structured data at the type level.

Walking the Object Tree
#

With string parsing in hand, you can recursively navigate a type by following a dot-path:

type GetByPath<
  T,
  Path extends string,
> = Path extends `${infer Head}.${infer Rest}`
  ? Head extends keyof T
    ? GetByPath<T[Head], Rest> // keep walking
    : never // path doesn't exist
  : Path extends keyof T
    ? T[Path] // leaf — return the type
    : never;

Feed it a User type and the path "address.city", and it resolves to string. But objectPick doesn’t just read a nested value — it reconstructs the entire path structure. Picking "address.city" from a user should return { address: { city: string } }, not just string.

So instead of returning the leaf type, you wrap each step:

type PickPath<T, P extends string> = P extends `${infer Head}.${infer Rest}`
  ? Head extends keyof T
    ? { [K in Head]: PickPath<T[Head], Rest> } // wrap and recurse
    : {}
  : P extends keyof T
    ? { [K in P]: T[P] } // leaf — wrap in object
    : {};

Now PickPath<User, "address.city"> gives { address: { city: string } }. Each recursion step wraps the result in a new object type with the current key. The structure mirrors the original — just pruned to only the path you asked for.

The Array Problem
#

Objects are one thing. Arrays introduce a fork in the road.

When someone writes "items.0.name", they want the name from the first element. When they write "items.*.name", they want name from every element. Both need to produce an array type in the result. The type system needs to detect “this value is an array” and handle the next segment differently:

type PickPath<T, P extends string> = P extends `${infer Head}.${infer Rest}`
  ? Head extends keyof T
    ? T[Head] extends readonly (infer Elem)[]
      ? { [K in Head]: PickPath<Elem, Rest>[] } // array: pick from element, wrap in []
      : { [K in Head]: PickPath<T[Head], Rest> } // object: recurse normally
    : {}
  : P extends keyof T
    ? { [K in P]: T[P] }
    : {};

The T[Head] extends readonly (infer Elem)[] check is doing two things at once: testing if the value is an array and extracting the element type via infer. Whether the path says "items.0.name" or "items.*.name", the type-level result is the same — { items: { name: string }[] }. The runtime handles the distinction (index picks one element, wildcard picks all), but the types collapse to the same shape.

(This is a simplification — the actual implementation needs to handle wildcards on plain objects too, where * means “every value.” But the core pattern is the same: detect, extract, wrap.)

Merging Multiple Paths
#

objectPick(user, ["name", "address.city"]) picks two independent paths. Each produces its own type:

  • "name" becomes { name: string }
  • "address.city" becomes { address: { city: string } }

You need to merge them into { name: string; address: { city: string } }. TypeScript’s intersection operator (&) gets you most of the way:

{ name: string } & { address: { city: string } }

But intersections are lazy — they don’t flatten into a clean object type. IDE tooltips show the raw A & B & C mess instead of the merged shape. And array intersections are worse: { items: A[] } & { items: B[] } doesn’t give you { items: (A & B)[] } — it gives you something the compiler chokes on.

The DeepSimplify Trick
#

Force TypeScript to eagerly evaluate the intersection by re-mapping every key:

type DeepSimplify<T> = T extends readonly any[]
  ? DeepSimplify<T[number]>[]
  : T extends object
    ? { [K in keyof T]: DeepSimplify<T[K]> }
    : T;

The { [K in keyof T]: DeepSimplify<T[K]> } pattern forces the compiler to walk the entire type and produce a flat object. No more lazy intersections in tooltips.

The array branch uses T[number] instead of infer E — a lesson learned the hard way. (A[] & B[])[number] correctly resolves to A & B, which then simplifies through the object branch. The infer approach fails here because TypeScript infers never from array intersections. Small detail, cost me an hour.

Distributing Over a Union
#

When the function receives ["name", "address.city"], TypeScript infers P as the union "name" | "address.city". You need to pick each path independently, then intersect:

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I,
) => void
  ? I
  : never;

type PickAll<T, P extends string> = UnionToIntersection<
  P extends any ? PickPath<T, P> : never
>;

P extends any ? PickPath<T, P> : never distributes over the union — TypeScript evaluates PickPath for each member independently. Then UnionToIntersection collapses the union of results into an intersection. Finally, DeepSimplify flattens it into a clean type.

This is the part that feels like actual programming. You’re mapping over a collection, transforming each element, then reducing the results. It’s map and reduce — at compile time.

The const Keyword That Changes Everything
#

One critical detail makes this all work: TypeScript needs to infer the literal string types from the paths array, not just string[].

// Without const: paths is string[] — all type info lost
function objectPick<T extends object>(obj: T, paths: string[]): ???

// With const: paths is readonly ["name", "address.city"]
function objectPick<const P extends readonly string[]>(obj: T, paths: P): ???

The const type parameter (TypeScript 5.0+) tells the compiler to infer the narrowest possible type. Without it, ["name", "address.city"] widens to string[] and you can’t do any path-based inference. With it, you get the literal tuple ["name", "address.city"], and the type system can parse each string.

Before const type parameters existed, you had to use as const at the call site — objectPick(user, ["name", "address.city"] as const). It worked but leaked implementation details to every caller. The const modifier on the generic parameter absorbs that responsibility into the function signature where it belongs.

Why This Matters Beyond the Exercise
#

You’ve used these patterns already — you just didn’t know it. Every time you write a Zod schema and call z.infer<typeof schema>, Zod is using recursive conditional types and infer to derive a TypeScript type from your runtime schema definition. When Drizzle gives you typed query results, it’s parsing your query builder chain at the type level. When tRPC infers your API types end-to-end, it’s doing exactly this kind of template literal parsing and recursive type construction.

The difference between “types as documentation” and “types as a program” is the difference between annotating your code and having the compiler derive correctness from your code. You don’t declare what objectPick returns — the type system computes it from the inputs.

TypeScript’s type system is Turing-complete. That’s not trivia — it’s a design tool. The same patterns that power objectPick power the libraries you use every day. Understanding them doesn’t just make you better at TypeScript. It changes how you think about what a type system is for.

The Toolbox
#

If you want to build your own type-level utilities, here’s the cheat sheet:

PatternWhat It DoesExample
infer in template literalsParse strings`${infer Head}.${infer Rest}`
Recursive conditional typesWalk nested structuresT extends ... ? Recurse<T[K]> : BaseCase
Mapped typesTransform object shapes{ [K in keyof T]: Transform<T[K]> }
UnionToIntersectionMerge union membersA | B becomes A & B
DeepSimplifyFlatten lazy intersectionsA & B becomes { ...A, ...B }
const type parameterPreserve literal types<const P extends string[]>
[keyof T] extends [never]Detect empty objectsPrevents {} propagation

Putting It All Together
#

Here’s the complete implementation — types and runtime — with every piece annotated. The flowchart below shows how a call like objectPick(obj, ["a.b.c", "items.*.name"]) flows through both layers.

objectPick flow — compile time types on the left, runtime extraction on the right

And the full annotated source:

// ════════════════════════════════════════════════
// TYPE UTILITIES — run at compile time only
// ════════════════════════════════════════════════

// Detect empty object types: [keyof R] extends [never] means R has no keys.
// Wrapped in tuple to prevent union distribution.
type IsEmpty<R> = [keyof R] extends [never] ? true : false;

// Wrap a recursive result in { Key: Result } or { Key: Result[] }.
// Returns {} if Result is empty — prevents { a: { b: {} } } from propagating.
type Wrap<Key extends string, Result, AsArray = false> =
  IsEmpty<Result> extends true
    ? {}
    : AsArray extends true
      ? { [K in Key]: Result[] }
      : { [K in Key]: Result };

// Handle array values: when we hit an array in the path, skip the index
// or wildcard segment and continue picking from the element type.
type PickFromArray<Key extends string, Elem, Rest extends string> =
  // "0.name" — numeric index followed by more path
  Rest extends `${number}.${infer Deep}`
    ? Wrap<Key, PickPath<Elem, Deep>, true>
    : // "*.name" — wildcard followed by more path
      Rest extends `*.${infer Deep}`
      ? Wrap<Key, PickPath<Elem, Deep>, true>
      : // "0" or "*" alone — pick entire element
        Rest extends `${number}` | "*"
        ? { [K in Key]: Elem[] }
        : {}; // invalid segment

// Core recursive type: resolve a single dot-path into a nested picked type.
// "a.b.c" on { a: { b: { c: string, d: number } } }
//   → { a: { b: { c: string } } }
type PickPath<T, P extends string> =
  // Has a dot? Split into Head.Rest
  P extends `${infer Head}.${infer Rest}`
    ? Head extends keyof T
      ? T[Head] extends readonly (infer E)[]
        ? PickFromArray<Head, E, Rest> // array value → delegate
        : Wrap<Head, PickPath<T[Head], Rest>> // object value → recurse
      : {} // Head doesn't exist on T
    : // No dot — terminal segment (leaf)
      P extends keyof T
      ? { [K in P]: T[P] } // wrap leaf value
      : {}; // key doesn't exist

// Convert a union to an intersection: A | B → A & B.
// Works by exploiting contravariant position of function parameters.
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I,
) => void
  ? I
  : never;

// Pick each path independently (distributes over union), then intersect.
// Guards against broad "string" type (empty array default) and "never".
type PickAll<T, P extends string> = string extends P
  ? {}
  : [P] extends [never]
    ? {}
    : UnionToIntersection<P extends any ? PickPath<T, P> : never>;

// Flatten intersection types into clean objects.
// Array branch uses T[number] — (A[] & B[])[number] = A & B,
// which then simplifies correctly through the object branch.
// Using `infer E` here fails: TS infers `never` from array intersections.
type DeepSimplify<T> = T extends readonly any[]
  ? DeepSimplify<T[number]>[]
  : T extends object
    ? { [K in keyof T]: DeepSimplify<T[K]> }
    : T;

// ════════════════════════════════════════════════
// RUNTIME — executes at JavaScript level
// ════════════════════════════════════════════════

// A spec tree describes which paths to pick.
// Leaf = true (pick entire value), branch = nested spec.
// Example: ["a.b.c", "a.b.d", "f"]
//   → { a: { b: { c: true, d: true } }, f: true }
type Spec = { [key: string]: true | Spec };

// Build a spec tree from an array of dot-paths.
// Overlapping paths collapse: "a.b" + "a.b.c" → "a.b" (parent wins).
function buildSpec(paths: readonly string[]): Spec {
  const spec: Spec = {};

  for (const path of paths) {
    let node = spec;
    const segments = path.split(".");

    for (let i = 0; i < segments.length; i++) {
      const seg = segments[i]!;
      if (node[seg] === true) break; // parent already picks everything

      if (i === segments.length - 1) {
        node[seg] = true; // leaf — pick whole value
      } else {
        if (!node[seg]) node[seg] = {}; // create branch if needed
        node = node[seg] as Spec; // descend
      }
    }
  }

  return spec;
}

// Recursively extract values from source following the spec tree.
function extract(source: unknown, spec: Spec): any {
  if (source == null || typeof source !== "object") return {};

  const isArr = Array.isArray(source);
  const result: any = isArr ? [] : {};

  for (const key of Object.keys(spec)) {
    const sub = spec[key]!;

    // Wildcard: apply sub-spec to every element (array) or property (object)
    if (key === "*") {
      if (isArr) {
        for (const elem of source as any[]) {
          const picked =
            sub === true ? structuredClone(elem) : extract(elem, sub);
          if (!isEmpty(picked)) result.push(picked);
        }
      } else {
        const src = source as Record<string, unknown>;
        for (const k of Object.keys(src)) {
          const picked =
            sub === true ? structuredClone(src[k]) : extract(src[k], sub);
          if (!isEmpty(picked)) result[k] = picked;
        }
      }
      continue;
    }

    // Numeric index on array: pick single element by position
    if (isArr && /^\d+$/.test(key)) {
      const elem = (source as any[])[Number(key)];
      if (elem === undefined) continue;
      const picked = sub === true ? structuredClone(elem) : extract(elem, sub);
      if (!isEmpty(picked)) result.push(picked);
      continue;
    }

    // Regular object key
    const src = source as Record<string, unknown>;
    if (!(key in src)) continue;
    const picked =
      sub === true ? structuredClone(src[key]) : extract(src[key], sub);
    if (!isEmpty(picked)) result[key] = picked;
  }

  return result;
}

// Check if a value is "empty" (null, empty array, empty object)
function isEmpty(v: unknown): boolean {
  if (v == null) return true;
  if (Array.isArray(v)) return v.length === 0;
  if (typeof v === "object") return Object.keys(v as object).length === 0;
  return false;
}

// ════════════════════════════════════════════════
// THE FUNCTION
// ════════════════════════════════════════════════

export const objectPick = <T extends object, P extends string = string>(
  obj: T,
  paths: P[],
): DeepSimplify<PickAll<T, P>> => {
  // Runtime: build spec tree, walk object, cast to computed type
  return extract(obj, buildSpec(paths)) as DeepSimplify<PickAll<T, P>>;
};

Where You’d Actually Use This
#

The objectPick utility is a teaching exercise, but the patterns behind it show up everywhere in real applications. Here’s where type-safe inference earns its keep.

API Response Shaping
#

Your backend returns a fat User object with 30 fields. Your frontend component needs three. Instead of Partial<User> (which says “any subset, maybe”) or a hand-written UserCardProps interface (which drifts from the API type), you derive the exact shape:

// The API type is the source of truth
type UserCard = PickByPaths<User, ["name", "avatar", "role.displayName"]>;
// { name: string; avatar: string; role: { displayName: string } }

One type, always in sync. When someone renames role.displayName to role.label on the backend, the compiler catches every component that references the old path.

Typed Event Systems
#

Event emitters are stringly-typed by default. Template literal parsing turns them into something the compiler can verify:

type EventMap = {
  user: { created: { id: string }; deleted: { id: string; reason: string } };
  order: { placed: { total: number }; cancelled: { orderId: string } };
};

// "user.created" → { id: string }
type EventPayload<Path extends string> = GetByPath<EventMap, Path>;

function emit<P extends string>(event: P, payload: EventPayload<P>): void;

emit("user.created", { id: "abc" });        // OK
emit("user.created", { id: 123 });           // ERROR: number not string
emit("user.exploded", { id: "abc" });        // ERROR: path doesn't exist

No enum of event names. No payload: unknown. The event string is the type.

Package Public APIs
#

If you’re building a shared package, these patterns let you expose a narrow, type-safe surface without manually maintaining parallel type definitions:

// Internal: big config object with everything
interface InternalConfig {
  db: { host: string; port: number; pool: { min: number; max: number } };
  cache: { ttl: number; strategy: "lru" | "fifo" };
  auth: { secret: string; issuer: string };
}

// Public: only what consumers need, derived from the source type
export type PublicConfig = PickByPaths<
  InternalConfig,
  ["db.host", "db.port", "cache.ttl", "cache.strategy"]
>;
// { db: { host: string; port: number }; cache: { ttl: number; strategy: "lru" | "fifo" } }

The internal config evolves freely. The public type tracks it automatically. No export of InternalConfig, no hand-maintained subset interface that quietly falls behind.

Form State from Schema
#

Full-stack frameworks like Remix and Next.js blur the line between server and client. When your form schema lives in Zod, you can derive the exact client-side state shape using the same recursive type patterns:

const formSchema = z.object({
  shipping: z.object({
    address: z.object({ line1: z.string(), city: z.string(), zip: z.string() }),
    method: z.enum(["standard", "express"]),
  }),
  billing: z.object({
    card: z.object({ last4: z.string(), expiry: z.string() }),
  }),
});

// Derive only the fields the shipping step needs
type ShippingStep = PickByPaths<
  z.infer<typeof formSchema>,
  ["shipping.address", "shipping.method"]
>;

Each form step gets exactly the fields it touches — not the whole schema, not a hand-picked subset that someone forgets to update when a field moves.

Aaron Yong
Author
Aaron Yong
Building things for the web. Writing about development, Linux, cloud, and everything in between.

Related

Automating Yourself Out of the Loop
·1512 words·8 mins
Photograph By Aleksandr Popov
Blog Software Engineering AI
How I went from reviewing every line to only making design decisions — and what it took to trust the agents
How AI Changed the Economics of Clean Code
·27 words·1 min
Photograph By freeCodeCamp
Blog Software Engineering AI FreeCodeCamp
AI made writing code nearly free. But humans still read it — and that changes the abstraction calculus entirely.
The Data Structure That's Okay With Being Wrong
·1340 words·7 mins
Photograph By Elimende Inagella
Blog Software Engineering Data Structures
Bloom filters — probabilistic, memory-efficient, and surprisingly useful