Return to TS

Practice

Three connected exercises: validate an update, keep the latest version, then save with bounded concurrency. Start with the contract and write your solution before opening the spoiler.

Domain vocabulary: Product catalog

A supplier feed reports product-state snapshots. The numeric field is the product’s price; its units and precision follow the exercise contract.

Entity: Product · ID: productId · Numeric field: price

The domain changes example names. Validation, key scope, ordering and failure behavior come from each exercise’s contract.

Review these chapters first

Practical TypeScript contracts · Collections and data processing · Implement a concurrency limiter · Code review, debugging and tests

1. Parse one update

Implement parseEntity(value: unknown): ProductUpdate. Throw an Error on invalid input; invalid input does not return null or undefined. Require own nonempty sourceId, eventId and productId strings, normalize them with trim, require a nonnegative safe integer version and finite nonnegative numeric price. Reject null, arrays, numeric strings and missing fields. Return a new object; unrelated fields are ignored. The numeric field represents the selected domain price, not a claim that every real business price follows this simplified contract.

Spoiler

Exclude null and arrays first, then establish each field type and business condition. Validate syntax before conversion when a different contract allows strings. The local Record assertion below is restricted to an already-checked object; each field is still checked at runtime. It is not an assertion that the whole input is valid.

export type ProductUpdate = {
  sourceId: string;
  eventId: string;
  productId: string;
  version: number;
  price: number;
};
export function parseEntity(value: unknown): ProductUpdate {
  if (typeof value !== 'object' || value === null || Array.isArray(value))
    throw new Error('Expected object');
  const object = value;
  function text(key: string): string {
    if (!Object.hasOwn(object, key)) throw new Error(`Missing ${key}`);
    const field = (object as Record<string, unknown>)[key];
    if (typeof field !== 'string' || !field.trim()) throw new Error(`Invalid ${key}`);
    return field.trim();
  }
  if (
    !('version' in value) ||
    typeof value.version !== 'number' ||
    !Number.isSafeInteger(value.version) ||
    value.version < 0
  )
    throw new Error('Invalid version');
  if (
    !('price' in value) ||
    typeof value.price !== 'number' ||
    !Number.isFinite(value.price) ||
    value.price < 0
  )
    throw new Error('Invalid price');
  return {
    sourceId: text('sourceId'),
    eventId: text('eventId'),
    productId: text('productId'),
    version: value.version,
    price: value.price,
  };
}

2. Deduplicate and keep the latest

Input is already valid. eventId is unique within sourceId; immutable duplicate deliveries count once. Select the highest version per sourceId/productId, keep the first at equal version and preserve first entity appearance. Do not mutate input. Check one ID in two sources, reversed versions, duplicate events and separator characters in IDs.

Spoiler

Use two independent keys. A Set records delivered events and a Map keeps entity winners. JSON encoding a pair avoids delimiter collisions. Replace only for a greater version; Map preserves the position of an existing key. This batch-only deduplication is not a durable inbox and retains O(events + entities) keys.

export type ProductUpdate = {
  sourceId: string;
  eventId: string;
  productId: string;
  version: number;
  price: number;
};
export function latestEntities(input: readonly ProductUpdate[]): ProductUpdate[] {
  const seen = new Set<string>();
  const latest = new Map<string, ProductUpdate>();
  for (const entity of input) {
    const eventKey = JSON.stringify([entity.sourceId, entity.eventId]);
    if (seen.has(eventKey)) continue;
    seen.add(eventKey);
    const entityKey = JSON.stringify([entity.sourceId, entity.productId]);
    const current = latest.get(entityKey);
    if (!current || entity.version > current.version) latest.set(entityKey, entity);
  }
  return [...latest.values()];
}

3. Process with partial failures

Accept already-validated entities, a positive safe integer limit and save(entity): Promise<void>. Bound active saves, preserve input result order, keep running after per-entity failures, and return an ok union for each entity. An invalid limit rejects even when input is empty. A synchronous throw and throw undefined are failures too.

Spoiler

Catch inside the limiter callback so expected failures become values. Await save before reporting success. Keep Error narrowing explicit. mapLimit is the helper from chapter 5 and examples/core.ts; it is imported here rather than duplicated. Type the union result to preserve literal discriminators.

import { mapLimit } from './core.ts';
export type ProductUpdate = {
  sourceId: string;
  eventId: string;
  productId: string;
  version: number;
  price: number;
};
export type Outcome = { ok: true; entity: ProductUpdate } | { ok: false; error: string };
export async function processEntities(
  input: readonly ProductUpdate[],
  limit: number,
  save: (entity: ProductUpdate) => Promise<void>,
): Promise<Outcome[]> {
  if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError('Invalid limit');
  return mapLimit<ProductUpdate, Outcome>(input, limit, async (entity) => {
    try {
      await save(entity);
      return { ok: true, entity };
    } catch (error) {
      return { ok: false, error: error instanceof Error ? error.message : String(error) };
    }
  });
}

Check your solution

Write and run your implementation in your usual TypeScript editor. Work through the checks below, then open each spoiler and compare the reasoning. The Copy code button copies a displayed example; it does not run or check your solution.

Parser checks

  • Valid input with surrounding spaces in the IDs and price: 0 → trimmed IDs, zero preserved and a new object. Extra fields are ignored.
  • null, an array, a missing field, a blank ID, a negative or fractional version, a numeric string, NaN or Infinity → throw an Error.
  • A required string inherited from the prototype → reject it; the contract requires own fields.

Deduplication checks

  • One entity at versions 3, 1, 3 → the first version-3 record wins.
  • Repeat the same sourceId/eventId → one delivery counts. The same entity ID in two sources → two separate winners.
  • IDs containing separators → remain distinct. Result order follows first entity appearance; input is unchanged.

Async checks

  • Saves finish in reverse order → results still follow input order; peak active saves never exceeds limit.
  • One save rejects or throws undefined → that position has ok: false; later items still run. A save must finish before its result has ok: true.
  • Empty input with a valid limit → []. An invalid limit, including with empty input → reject with RangeError.

Run your editor’s type checker and tests against your implementation. Finally, change one condition, such as the tie-breaking or failure policy, close the answers and implement the variation yourself.

Mark after your own attempt and answer review.