BIO-1-2

Translate DNA to protein in software, honoring frames and ORFs

20 min

In S5.5 you translated a codon by hand: read three bases, look them up, write down one amino acid, step forward three. You know the biology cold. This lesson is about handing that job to a computer, and the point is not that a machine is faster. The point is that writing the code forces you to be honest about three things a human quietly fudges. You have to pick a reading frame. You have to decide which strand you are reading. And you have to say, in precise terms, where a gene begins and ends, which turns out to be the hard part. Get those three right and you have written the core of a real bioinformatics tool. Get them wrong and your code will happily return a fluent-looking protein that no cell ever makes.

In BIO-1.1 you settled the representation: DNA over the alphabet A, C, G, T, RNA over A, C, G, U, protein over 20 letters plus a stop, all as plain strings with a defined direction. Now we put those strings to work.

The codon table is just a hash map

Here is the reassuring part for a programmer. The genetic code, the thing that took biology decades to crack, is a lookup table with 64 entries. There is no algorithm inside it, no rule to derive, just data: the key AUG maps to the value M, the key GCA maps to A, the key UAA maps to the stop marker. Translation is nothing more than a strided walk over the sequence that hits this table once per codon.

So the translator is a loop. Start at your chosen offset, slice out three bases, look them up, append the result, advance by three, repeat until fewer than three bases remain. The panel below mirrors translate from the app's frozen src/lib/bio/translation.ts. Read it and notice the two decisions baked in.

translation.ts
// The 64-codon table as a plain lookup map (RNA alphabet A, C, G, U).
// Stops map to "*". This is the whole genetic code: no logic, just data.
const CODON_TABLE: Record<string, string> = {
  UUU: "F", UUC: "F", UUA: "L", UUG: "L",
  AUG: "M",                      // the start codon, also Met mid-protein
  UAA: "*", UAG: "*", UGA: "*",  // the three stops carry no amino acid
  GCU: "A", GCC: "A", GCA: "A", GCG: "A",
  // ... the remaining codons, filled in one first-base block at a time
}

const STOP = "*"

// DNA or RNA, any case, in. We uppercase and rewrite every T as U so the
// same code decodes an mRNA (AUG...) or the coding-strand DNA (ATG...).
function toRna(seq: string): string {
  return seq.toUpperCase().replace(/T/g, "U")
}

// Read non-overlapping triplets starting at `frame`. A trailing 1 or 2
// leftover bases is dropped. By default we halt at the first in-frame stop
// and emit NO marker for it, the way a ribosome releases the finished chain.
function translate(mrna: string, frame = 0, stopAtStop = true): string {
  const seq = toRna(mrna)
  let protein = ""
  let i = frame
  while (i + 3 <= seq.length) {
    const aa = CODON_TABLE[seq.slice(i, i + 3)] ?? "X"
    if (aa === STOP) {
      if (stopAtStop) return protein
      protein += STOP
    } else {
      protein += aa
    }
    i += 3
  }
  return protein
}

One note before we read it: the real exported translate in this course takes an options object as its second argument (translate(mrna, opts), where opts carries frame and stopAtStop), so calling it positionally like translate(seq, 1) will not select a frame. The positional form above is a teaching simplification, and the logic and every output shown are identical.

The first decision is toRna. A codon table is written in RNA, but your input is very often a DNA string straight from a file. Rather than force the caller to transcribe first (recall transcribe from S3, T to U), the function normalizes internally: uppercase, then replace every T with U. Now translate("ATGGCA") and translate("AUGGCA") return the same thing, which is what you want, because the coding-strand DNA and the mRNA carry identical information apart from that one letter swap.

The second decision is stopAtStop. When true, the loop returns the moment it meets an in-frame stop and does not put a marker in the output, because a real ribosome releases the protein there and a stop is not an amino acid. That is why translating AUGUUUUAA gives you MF, not MF followed by anything. When false, the loop reads straight through and writes a "*" for each stop, which is exactly what you need to see every open stretch in a frame. Hold that second mode in mind, we are about to lean on it.

Three frames per strand, two strands, six frames

A raw sequence from a file does not come with the reading frame marked. Recall from S5.5 that a one-base shift regroups every codon, and because a codon is three bases wide there are exactly three distinct frames on a single strand: start at offset 0, 1, or 2. Offset 3 lands you back in step with offset 0, so there is nothing new past frame 2.

But DNA is double-stranded, and here BIO-1.1 and S3.2 pay off. The two strands are antiparallel: the complementary strand runs in the opposite direction, and it is a legitimate template that can carry its own gene reading the other way. So to see everything the DNA could encode you must also read the reverse complement, and that strand has its own three frames. Three plus three is six. Six-frame translation means: translate the given strand in frames 0, 1, 2, then translate its reverse complement in frames 0, 1, 2. That is the standard first pass over any anonymous sequence, and it is the reason a gene can hide on either strand of a genome browser track.

Notice the reverse complement, not merely the reverse. Reversing the string alone would read the same strand backward, which is biologically meaningless. Complement then reverse gives you the actual sequence a polymerase would read 5 prime to 3 prime on the other strand. This is the reverseComplement you met in S3.2, and getting it exactly right matters, because a plain reverse is a bug that silently produces plausible garbage.

An ORF runs from a start to the next in-frame stop

Now the hard part, stated precisely. An open reading frame, or ORF, is a stretch that begins at a start codon (AUG) and runs, in that same frame, to the next stop codon (UAA, UAG, or UGA), with no stop interrupting it in between. It is the longest run of codons you could translate straight through without hitting a terminator. Finding ORFs is the software translation of "where might a gene be", and it is built entirely out of pieces you already have: transcribe, reverse complement, and a frame walk that watches for a start and then a stop.

The panel below mirrors findORFs and its per-frame scanner from the frozen translation.ts. Read the state machine carefully. It is looking for a start, and once it has one it is looking for a stop.

orfs.ts
// CODON_TABLE and toRna as in translation.ts above.
const START = "AUG"
const STOPS = new Set(["UAA", "UAG", "UGA"])

interface Orf {
  strand: "+" | "-"
  frame: number
  start: number   // 0-based, half-open [start, end) on the INPUT sequence
  end: number
  protein: string
}

// Scan ONE already-transcribed strand in ONE frame. An ORF opens at the
// first AUG after the previous stop and closes at the next in-frame stop.
function scanFrame(rna: string, frame: number) {
  const found: Array<{ start: number, end: number, protein: string }> = []
  let i = frame
  let inOrf = false
  let start = -1
  let protein = ""
  while (i + 3 <= rna.length) {
    const codon = rna.slice(i, i + 3)
    if (!inOrf && codon === START) {
      inOrf = true
      start = i
      protein = "M"
    } else if (inOrf && STOPS.has(codon)) {
      found.push({ start, end: i + 3, protein })  // span includes the stop
      inOrf = false
      protein = ""
    } else if (inOrf) {
      protein += CODON_TABLE[codon] ?? "X"
    }
    i += 3
  }
  return found
}

// All six frames: three forward, three on the reverse complement (S3.2).
function findORFs(dna: string): Orf[] {
  const n = dna.length
  const forward = transcribe(dna)
  const reverse = transcribe(reverseComplement(dna))
  const orfs: Orf[] = []
  for (const frame of [0, 1, 2]) {
    for (const o of scanFrame(forward, frame)) {
      orfs.push({ strand: "+", frame, start: o.start, end: o.end, protein: o.protein })
    }
    for (const o of scanFrame(reverse, frame)) {
      // A reverse position p maps to input position n - 1 - p, so the
      // half-open interval flips to [n - end, n - start).
      orfs.push({ strand: "-", frame, start: n - o.end, end: n - o.start, protein: o.protein })
    }
  }
  return orfs
}

Two details earn their keep. The reported span includes the terminal stop codon (end is i + 3), but the protein string does not, because the stop is a signal, not a residue. And the reverse-strand coordinates get flipped back onto the input with n - end and n - start, exactly the trap the previous callout warned about, so that every ORF is reported in one consistent coordinate system. This is the half-open, 0-based interval convention you nailed down in BIO-1.1, and here is why that discipline was worth the trouble: without a single agreed coordinate system, plus and minus strand hits could never be compared.

Why this works for bacteria and breaks on you and me

For a bacterium, ORF-finding is genuinely close to gene-finding. Bacterial genomes are compact and gene-dense, genes usually run as one uninterrupted coding stretch from start to stop, and there is little sequence between them. Point findORFs at a bacterial genome, keep the long ORFs, and you have recovered most of the real genes with a script you could write this afternoon. This is not a toy result. It is why the technique is the first thing anyone reaches for.

Then you turn it on a human gene and it falls apart, and the reason is everything you learned in S5.4 and S6.2. A eukaryotic gene is not stored as one continuous coding sequence in the DNA. It is split into exons, the pieces that end up in the protein, interrupted by introns, stretches that are transcribed and then cut out before translation. Recall from S5.4 that splicing removes the introns and stitches the exons together to build the mature mRNA. So the clean ORF exists only in the spliced messenger RNA, never in the genomic DNA. In the genome the coding message is shattered across exons separated by introns that are full of stop codons in every frame. Run findORFs over that raw DNA and the very first intron ends your ORF early. The gene is real. The continuous open reading frame simply is not there to find.

It gets harder. From S6.2, most of a eukaryotic genome is not protein-coding at all, and which stretches actually become genes depends on regulation: promoters, enhancers, chromatin state, tissue and time. A stretch of DNA can hold a flawless long ORF and still never be transcribed into anything. Syntax (an open frame) does not settle function (a made protein). ORF-finding sees only the syntax.

Do it yourself

Reading the loop is not the same as watching a frame break under your hands. The compiler below is a live DNA-to-protein translator built over exactly these functions. Do four things with it, in order.

First, read the default sequence in frame 0 on the plus strand and write your predicted protein into the predict box before you reveal it, so you are decoding, not spectating. It reads as MALT before it hits a stop. Second, change the reading frame from 0 to 1 without touching a single base and watch every codon regroup into a different, usually nonsense, read. That is the frame idea, live. Third, flip the strand from plus to minus and confirm you are now reading the reverse complement from S3.2, a different sequence entirely, not the same string backward. Fourth, mutate a base: try a silent third-base swap, then a missense, then a single-base insert or delete and watch the frameshift ripple through every downstream codon while a substitution never does. That is S7.1, felt rather than defined.

translate.ts

Edit the sequence, or click any base below to mutate it. On the minus strand the reverse complement is read.

Mstart
A
L
T
STOPstop
Pick a base above to substitute, or use the indel controls.
Protein
MALT
Show the translation code

The whole breakdown above is this loop: read the mRNA three bases at a time, look each codon up in the genetic code, and stop at the first stop codon (just like a ribosome releasing the finished chain).

translate.ts
// translate.ts: the ribosome as a loop over codons
const CODON_TABLE: Record<string, string> = {
  AUG: "M", GCA: "A", CUG: "L", ACC: "T",
  UAA: "*", UAG: "*", UGA: "*", /* ...all 64 codons... */
};

function translate(mrna: string): string {
  const rna = mrna.toUpperCase().replace(/T/g, "U");
  let protein = "";
  for (let i = 0; i + 3 <= rna.length; i += 3) {
    const aa = CODON_TABLE[rna.slice(i, i + 3)] ?? "X";
    if (aa === "*") break; // ribosome releases at the first stop
    protein += aa;
  }
  return protein;
}

translate("AUGGCACUGACCUAA"); // "MALT"

Key terms

codon table
The 64-entry lookup mapping each RNA triplet to one amino acid or a stop. Pure data, no logic, so translation is a strided walk that queries it once per codon.
reading frame
The starting offset (0, 1, or 2) that fixes how a continuous base stream is grouped into codons. Three frames exist on each strand.
six-frame translation
Translating a sequence in all six frames: the three forward frames of the given strand plus the three frames of its reverse complement.
open reading frame (ORF)
A run of codons from a start (AUG) to the next in-frame stop with no stop between them. A candidate coding region, not a confirmed gene.
reverse complement
The minus-strand sequence read 5 prime to 3 prime: complement every base, then reverse the order. Not the same as reversing the string.
exon and intron
An exon is a coding piece kept in the mature mRNA. An intron is a transcribed stretch spliced out before translation, which is why a eukaryotic ORF is not intact in the genome (S5.4).
gene prediction
The inference of where genes actually are, beyond raw ORFs, using splice-site, codon-usage, and regulatory signals modeled with HMMs or machine learning (BIO-5.3).
Why prokaryotic ORF-finding really is close to gene-finding

It is worth being precise about why the bacterial case is so friendly. Prokaryotic genomes are under strong pressure toward compactness, so coding density is high, often more than 85 percent of the genome, and genes are typically colinear with their mRNA (no introns to splice out). Genes are frequently organized into operons, several genes transcribed as one unit, and each coding start is marked by a short ribosome-binding motif (the Shine-Dalgarno sequence) sitting just upstream of the AUG. A predictor can exploit that motif plus codon-usage statistics to pick the true start among several candidate AUGs, which is the one genuinely tricky part even here. Eukaryotes replace the Shine-Dalgarno motif with a different start context (the Kozak sequence) and, fatally for naive ORF scanning, break the coding sequence across exons. So the difference is not that eukaryotic biology is fuzzier, it is that the coding message is physically discontinuous in the DNA and its expression is conditional on regulation. The honest summary: ORF-finding is a strong heuristic exactly when genes are dense and unspliced, and a weak one the moment they are not.

Check yourself

1. Why does a six-frame translation read six frames rather than three?

2. With stopAtStop set to true, what does translate('AUGUUUUAA') return, and why?

3. You run findORFs on raw human genomic DNA and a known protein-coding gene shows up only as several short ORFs, not one long one. What is the most likely explanation?

4. A colleague reads the minus strand by reversing the DNA string without complementing it. What goes wrong?

4 unanswered