Generic Result Types

The Course
•
5 min read

Beginner used a result union for one parser. A generic Result<T> reuses that relationship across operations: success contains T, failure contains a structured Issue. The boolean literal ok is the discriminant, just as kind was in the earlier course.

{ ok:true; value:T } | { ok:false; issue:Issue } does not permit a success without data or a failure without details. Always narrow before reading variant-specific fields. A generic result is still an ordinary runtime object; its type does not make the operation succeed.

Use one failure representation consistently across a small boundary. Our Issue has path and message so callers can identify a malformed row or field without parsing a prose sentence. Returning an Issue is useful for expected bad data. Unexpected programming defects should not automatically be disguised as valid error results.

The example maps a successful value while retaining the same failure branch. No assertion is necessary because the branch gives the checker enough evidence.

Predict, run, and explain

Write the output and trace the important state changes before checking it. Then run the program. Use Check types first; execution is allowed only when the real compiler accepts the source.

Predict, then runTypeScript
type Issue = {
  path: string;

  message: string;
};

type Result<T> =
  | {
      ok: true;

      value: T;
    }
  | {
      ok: false;

      issue: Issue;
    };

function mapResult<A, B>(
  result: Result<A>,
  transform: (value: A) => B
): Result<B> {
  if (!result.ok) {
    // Preserve the failure without calling transform.
    return result;
  }

  return { ok: true, value: transform(result.value) };
}

const result = mapResult<number, string>(
  { ok: true, value: 25 },
  value => `${value} min`
);

console.log(result.ok ? result.value : result.issue.message);

Enable JavaScript to edit and type-check this example.

Check your prediction
25 min

Read the compiler error

The result is not known to be the success variant here.

Find and fix the type errorTypeScript
type Result<T> =
  | {
      ok: true;

      value: T;
    }
  | {
      ok: false;

      message: string;
    };

function read(result: Result<number>): number {
  return result.value;
}

Enable JavaScript to edit and type-check this example.

Check your diagnosis

Narrow on ok and decide what the failure branch should do. Do not use an assertion or a non-null shortcut to pretend every result succeeded.

Map success without inventing success from failure

A Result<T> describes a known success value type while preserving an explicit expected-failure branch. A transformation should apply its callback only to success and carry failure through unchanged. Otherwise it can run code on data that was never produced.

The compiler enforces branch access and the declared output relationship. It cannot prove that the callback runs the intended number of times or that error identity was preserved. Keep those as observable runtime checks.

Map success without inventing success from failure: traceTypeScript
type Result<T> =
  | {
      ok: true;

      value: T;
    }
  | {
      ok: false;

      message: string;
    };

function map<T, U>(result: Result<T>, fn: (value: T) => U): Result<U> {
  return result.ok ? { ok: true, value: fn(result.value) } : result;
}

const result = map({ ok: true, value: 0 }, value => value + 1);

console.log(result.ok ? result.value : result.message);

Enable JavaScript to edit and type-check this example.

Check your prediction
1

Zero enters the success mapper because ok carries the outcome. A truthy-value condition would lose a valid success.

Map success without inventing success from failure: contrastTypeScript
type Result<T> =
  | {
      ok: true;

      value: T;
    }
  | {
      ok: false;

      message: string;
    };

function map<T, U>(result: Result<T>, fn: (value: T) => U): Result<U> {
  return result.ok ? { ok: true, value: fn(result.value) } : result;
}

let calls = 0;
const failure: Result<number> = { ok: false, message: "bad" };
const result = map(failure, value => {
  calls++;
  return String(value);
});

console.log(calls, result === failure);

Enable JavaScript to edit and type-check this example.

Check your prediction
0 true

No transformation ran and the original failure object survived. The output can use another success type without manufacturing a value on the failure path.

Try the rule on new inputs

Test success with zero, a normal success and failure. Make the mapper throw and decide whether that programming error should propagate or become a separate expected failure; document the policy before changing it.

Your turn

Exercise

Generic Result Types: practise

Define Result<T> and a describe function for Result<number>. A success prints the number as text; failure prints path: message. Show 0 and pages: Invalid count. Preserve meaningful zero.

Your workspaceTypeScript
// Narrow with result.ok before reading value or issue.

Enable JavaScript to edit and type-check this example.

Hint 1: the idea

A boolean literal discriminant carries the relationship between fields.

Hint 2: a first step

Use an if branch rather than checking whether the numeric value is truthy.

Show one solution
One possible solutionTypeScript
type Issue = {
  path: string;

  message: string;
};

type Result<T> =
  | {
      ok: true;

      value: T;
    }
  | {
      ok: false;

      issue: Issue;
    };

function describe(result: Result<number>): string {
  if (result.ok) {
    return String(result.value);
  }

  return `${result.issue.path}: ${result.issue.message}`;
}

console.log(describe({ ok: true, value: 0 }));
console.log(
  describe({ ok: false, issue: { path: "pages", message: "Invalid count" } })
);

Enable JavaScript to edit and type-check this example.

0
pages: Invalid count

Explain why the implementation meets the stated contract. Which part is enforced by the language or checker, and which part needs behavioral evidence?

Change it and test it

Try reading value without narrowing and inspect the error. Create an invalid success missing value and confirm checking fails. Explain why a well-typed decoder can still return a dishonest result unless its runtime checks are tested.

Was this article helpful?

Your response is saved on this device.