TypeScript, Literal Types, and Human-Readable Code
In the history of programming languages, there’s an emphasis on human communication. It’s even right there in the name: if that hadn’t been…
TypeScript, Literal Types, and Human-Readable Code
In the history of programming languages, there’s an emphasis on human communication. It’s even right there in the name: if that hadn’t been the case, a word other than “language” probably would have been chosen.
Donald Knuth called it “literate programming.” COBOL was an interesting (but predictably soulless) attempt to make code readable by…businesspeople (cue jokes about the questionable humanity of businesspeople). ALGOL was an ambitious attempt to move towards human-readable code — and the syntax of most popular languages today directly descend from ALGOL.
As we approach the end of 2025, the lumbering giant that is generative AI portends a new angle in the discussion of human-readable coding. While an LLM is not a language in the traditional sense, there are parallels: programmers in 2025 write human-language prompts that are rendered into code that is then executed by machine…the primary difference between this and a programming language is that the output is not deterministic. While this is an interesting topic, it’s not what I’m here to write about today.
Is TypeScript Even a Programming Language?
It would be a pretty easy argument to make that TypeScript is not actually a programming language itself, but a…fancy linter? Documentation syntax? After all, TypeScript (with a few exceptions) is meant to only to provide and enforce type constraints to JavaScript. Case in point: the most common approach to running TypeScript on an actual JavaScript engine is just to…strip away the type annotations. There’s no translation as there is in a typical programming language, just removal.
And there is a growing (and, in my mind, surprising and wrong-headed) backlash against TypeScript in favor of the loosey-goosey ease of JavaScript, or at least a compromise like Flow or linters that enforce TypeScreipt-like type annotations in JSDoc. Boo, I say.
The counter-argument is a more practical one: if two people came to me proposing a large, complicated project to be built by more than one programmer, and one team insisted it be written in JavaScript, and the other wanted it written in TypeScript, I’m not sure what the multiple of money the former team would have to pay me to take that project over the TypeScript project. (It would be a big multiplier…my sanity is valuable!)
Literal String Types
Literal string types have been around in TypeScript a long time (since 1.8, according to Claude…I haven’t checked that fact!), and I think they’re one of the more underapprciated and underutilized features of TypeScript. And they’re the subject of this article: specifically, how they support human-readable code.
A React Hook Example
A common pattern in a React app is using a stateful index into an array to determine what to render. Think carousels, stepped controls (like a “wizard”, though I’ve always hated that name), paginated data, etc. Here’s a simple example, without a custom hook:
function WordCarousel() {
const WORDS = ['Eggplant', 'Xerces', 'Crybaby', 'Overbite', 'Narwhale']
const [idx, setIdx] = useState(0)
return (
<div>
<div>{WORDS[idx]}</div>
<button
onClick={() => setIdx(idx => idx === WORDS.length - 1 ? 0 : idx + 1)}
>Prev</div>
<button
onClick={() => setIdx(idx => idx === 0 ? WORDS.length - 1: idx - 1)}
>Next</div>
</div>
)
}
Just reading this ugly code, we can infer the requirement for this particular carousel: namely, it repeats infinitely (if you go past “Narwhale”, your’e back to “Eggplant”, and if you go back from “Eggplant”, your’e at “Narwhale” again).
It would be very easy to write a hook to handle this specific articulation of a index stepper. I won’t bore you with that, but let’s consider the other ways we may wish a general-purpose index stepper to behave, the other features we may wish it to have:
- No rollover at the beginning (e.g. can’t go back from the first index)
- No rollover at the end (e.g. can’t go forward from the last index)
- Ability to jump to a specific index
- What to do if any of the constraints get violated
Here’s one yawn-inducing approach to a hook that satisfies all of these use cases:
export function useIndexStepper(args: {
stepCount: number;
canGoBackFromFirstStep: boolean;
canGoForwardFromLastStep: boolean;
errorOnViolation: boolean;
}) {
// ...implementation
return { stepIdx, setStepIdx, nextStep, prevStep, isFirst, isLast };
}
Maybe this seems okay at first glance, but I find it kind of awful. First of all, it’s grotesquely verbose. canGoBackFromFirstStep? It’s clear enough (which is good), but we can do better. Secondly, I think additional documentation is required to make clear what errorOnViolation actually means…without writing throwErrorWhenAttemptingToGoBackFromFirstStepOrForwardFromLastStepWhenTheCorrespondingOptionDoesNotSupportIt. Even the most sesquipedalian Java programmer would probably wince at that one (maybe there are some German-speaking sesquipedalian Java programmers who would appreciate it?).
This is an example where I think the friendly use of literal strings really shines. Here’s what I like (with implementation):
function useIndexStepper(args: {
stepCount: number;
rollover?:
| "On First Step"
| "On Last Step"
| "On First and Last Steps"
| "Never";
onRolloverError?: "Throw Error" | "Ignore";
}) {
const {
stepCount,
rollover = "On First and Last Steps",
onRolloverError = "Throw Error",
} = args;
if (stepCount === 0) throw new Error(`step count must be greater than 0`);
const [stepIdx, _setStepIdx] = useState(0);
const setStepIdx = (idx: number) =>
_setStepIdx(() => {
if (idx < 0 || idx > stepCount - 1) throw new Error(`invalid index: ${idx}`);
return idx;
});
const nextStep = () =>
_setStepIdx(idx => {
if (idx < stepCount - 1) return idx + 1;
if (rollover == "On Last Step" || rollover === "On First and Last Steps") return 0;
if (onRolloverError === "Throw Error") throw new Error(`cannot advance past last step`);
return idx;
});
const prevStep = () =>
_setStepIdx(idx => {
if (idx > 0) return idx - 1;
if (rollover == "On First Step" || rollover === "On First and Last Steps") return stepCount - 1;
if (onRolloverError === "Throw Error") throw new Error(`cannot go back from first step`);
return idx;
});
const isFirst = stepIdx === 0;
const isLast = stepIdx === stepCount - 1;
return { stepIdx, setStepIdx, nextStep, prevStep, isFirst, isLast };
}
Now consider some implementations:
const { stepCount, onPrev, onNext } = useIndexStepper({
stepCount: WORDS.length,
rollover: "On First and Last Steps",
})
return (
<div>
<div>{WORDS[idx]}</div>
<button onClick={onPrev}>Prev</button>
<button onClick={onNext}>Next</button>
</div>
)
Pretty clear what’s going on here! And the code is nice and compact.
const { stepCount, onPrev, onNext, isFirst, isLast} = useIndexStepper({
stepCount: WORDS.length,
rollover: "Never",
})
return (
<div>
<div>{WORDS[idx]}</div>
<button onClick={onPrev} disabled={isFirst}>Prev</button>
<button onClick={onNext} disabled={isLast}>Next</button>
</div>
)
Clear here too, though types alone don’t fully protect us here…if we don’t correctly use the disabled property, this will result in runtime exceptions…which is a sensible default. To wit, the following will results in a confusing UI with no indication (like an exception) that something is wrong:
const { stepCount, onPrev, onNext, isFirst, isLast} = useIndexStepper({
stepCount: WORDS.length,
rollover: "Never",
onRolloverError: "Ignore",
})
return (
<div>
<div>{WORDS[idx]}</div>
<button onClick={onPrev}>Prev</button>
<button onClick={onNext}>Next</button>
</div>
)
And now that I see this last case (a confusing UI), I realize there’s another use case we may want to support: custom actions on rollover errors. Fortunately, this is an easy addition to our hook:
function useIndexStepper(args: {
stepCount: number;
rollover: "On First Step" | "On Last Step" | "On First and Last Steps" | "Never";
onRolloverError: "Throw Error" | "Ignore" | ((position: "First" | "Last") => void);
}) {
const { stepCount, rollover, onRolloverError } = args;
if (stepCount === 0) throw new Error(`step count must be greater than 0`);
const [stepIdx, _setStepIdx] = useState(0);
const setStepIdx = (idx: number) =>
_setStepIdx(() => {
if (idx < 0 || idx > stepCount - 1) throw new Error(`invalid index: ${idx}`);
return idx;
});
const nextStep = () =>
_setStepIdx(idx => {
if (idx < stepCount - 1) return idx + 1;
if (rollover == "On Last Step" || rollover === "On First and Last Steps") return 0;
if (onRolloverError === "Throw Error") throw new Error(`cannot advance past last step`);
if (typeof onRolloverError === "function") onRolloverError("Last");
return idx;
});
const prevStep = () =>
_setStepIdx(idx => {
if (idx > 0) return idx - 1;
if (rollover == "On First Step" || rollover === "On First and Last Steps") return stepCount - 1;
if (onRolloverError === "Throw Error") throw new Error(`cannot go back from first step`);
if (typeof onRolloverError === "function") onRolloverError("First");
return idx;
});
const isFirst = stepIdx === 0;
const isLast = stepIdx === stepCount - 1;
return { stepIdx, setStepIdx, nextStep, prevStep, isFirst, isLast };
}
And now we can support a “friendly” UI (assuming we feel there is some utility in letting the user perform an invalid action and telling them after the fact):
const { stepCount, onPrev, onNext, isFirst, isLast} = useIndexStepper({
stepCount: WORDS.length,
rollover: "Never",
onRolloverError: position => alert(position === "First"
? "You can't go back from the first item, chum!"
: "You can't go past the last item, chuckles!"
),
})
return (
<div>
<div>{WORDS[idx]}</div>
<button onClick={onPrev}>Prev</button>
<button onClick={onNext}>Next</button>
</div>
)
Type Safety Madness (or Nirvana)
There is a “semantic type lie” in our hook. Even if we say rollover: "Never , we still get back functions for prevStep and nextStep even when those functions will result in an error (which is why we need the onRolloverError prop).
There is a very complicated solution (that also contains a difficult-to-eliminate React code smell, which I’ve chosen to ignore for this article). I’m not proposing that the solution I’m about to present is better; the juice is not really worth the squeeze here (not even considering the React code smell). However, it does illustrate a useful concept that I have used to great effect in other contexts: constraining return types with input types. It’s a dizzying effort here:
function useIndexStepper(args: { stepCount: 0 }): never;
function useIndexStepper(args: { stepCount: number }): IndexStepperResultsBase & {
nextStep: VoidFunction;
prevStep: VoidFunction;
};
function useIndexStepper(args: { stepCount: number; rollover: "On Last Step" }): IndexStepperResultsBase & {
nextStep: VoidFunction;
prevStep: VoidFunction | undefined;
};
function useIndexStepper(args: { stepCount: number; rollover: "On First Step" }): IndexStepperResultsBase & {
nextStep: VoidFunction | undefined;
prevStep: VoidFunction;
};
function useIndexStepper(args: { stepCount: number; rollover: "On First and Last Steps" }): IndexStepperResultsBase & {
nextStep: VoidFunction;
prevStep: VoidFunction;
};
function useIndexStepper(args: { stepCount: number; rollover: "Never" }): IndexStepperResultsBase & {
nextStep: VoidFunction | undefined;
prevStep: VoidFunction | undefined;
};
function useIndexStepper(args: {
stepCount: number;
rollover?: "On First Step" | "On Last Step" | "On First and Last Steps" | "Never";
}) {
const { stepCount, rollover = "Never" } = args;
const [stepIdx, _setStepIdx] = useState(0);
const setStepIdx = (idx: number) =>
_setStepIdx(() => {
if (idx < 0 || idx > stepCount - 1) throw new Error(`invalid index: ${idx}`);
return idx;
});
const isFirst = stepIdx === 0;
const isLast = stepIdx === stepCount - 1;
switch (rollover) {
case "On First Step": {
const nextStep = stepIdx < stepCount - 1 ? () => _setStepIdx(stepIdx + 1) : undefined;
const prevStep = () => _setStepIdx(stepIdx === 0 ? stepCount - 1 : stepIdx + 1);
return { stepIdx, setStepIdx, nextStep, prevStep, isFirst, isLast };
}
case "On Last Step":
const nextStep = () => _setStepIdx(stepIdx === stepCount - 1 ? 0 : stepIdx + 1);
const prevStep = stepIdx === 0 ? undefined : () => _setStepIdx(stepIdx - 1);
return { stepIdx, setStepIdx, nextStep, prevStep, isFirst, isLast };
case "On First and Last Steps": {
const nextStep = () => _setStepIdx(stepIdx === stepCount - 1 ? 0 : stepIdx + 1);
const prevStep = () => _setStepIdx(stepIdx === 0 ? stepCount - 1 : stepIdx + 1);
return { stepIdx, setStepIdx, nextStep, prevStep, isFirst, isLast };
}
case "Never": {
const nextStep = () => _setStepIdx(stepIdx === stepCount - 1 ? 0 : stepIdx + 1);
const prevStep = () => _setStepIdx(stepIdx === 0 ? stepCount - 1 : stepIdx + 1);
return { stepIdx, setStepIdx, nextStep, prevStep, isFirst, isLast };
}
default:
throw new Error("nope");
}
}
As you can see, this is…a lot of work. Is the benefit worth the effort? In this case, probably not (though I would consider this if I were making this a open-sourced utility package…though I would want to solve the React gotcha).
Speaking of the React code smell, if you haven’t spotted it already, it’s the fact that the prevStep and nextStep functions are using the value of stepIdx, which they directly modify, instead of using an updater function (as the simpler version does). (See the React *State is a Snapshot article if you don’t understand why this is problematic.) This is not a simple problem here because we can’t* put the boundary test (stepIdx === 0, for example) inside the updater function because it defeats the purpose of what we’re trying to do. Of course we could still use an updater when the function is defined, but in for a penny in for a pound here…I’d rather have an approach that’s never safe than one that’s safe sometimes and unsafe others.
But we do have a hook now that comprehensively identifies invalid behavior at compile-time…and that’s not nothing. Specifically, if the state is such that it’s not valid to call onPrev, well…that’s just undefined now. It essentially also eliminates the need for isFirst and isLast (though I left them because they may be handy for other reasons). We also no longer need the onRolloverError parameter. I would also argue that it makes the semantics of why the buttons are disabled clearer:
const { stepCount, onPrev, onNext } = useIndexStepper({
stepCount: WORDS.length,
rollover: "Never",
})
return (
<div>
<div>{WORDS[idx]}</div>
<button onClick={onPrev} disabled={onPrev === undefined}>Prev</button>
<button onClick={onNext} disabled={onNext === undefined}>Next</button>
</div>
)
Again, the juice is not worth the squeeze here: it’s a fussy, complicated implementation, it contains a difficult-to-eliminate React no-no, and the gain is pretty minimal. But the idea is quite potent, and the next time I run into a real-world example, maybe I’ll write a follow-up article.
Conclusion
TypeScript literal string types open up entirely new ways to think about encoding domain logic in a way that’s both compile-time verifiable and very clear, self-documenting, and developer friendly. I’ve been using techniques like this for a couple of years now, I’m always finding more and better ways to use literal string types, and I even feel like it’s resulted in a fundamental shift in the way I think about design and architecture (similar to the shift in thinking when moving from imperative to functional programming).
메타데이터
- post_id
- a50fdcf0474b
- slug
- typescript-literal-types-and-human-readable-code-a50fdcf0474b
- url
- https://medium.com/@EthanRBrown/typescript-literal-types-and-human-readable-code-a50fdcf0474b
- canonical_url
- https://medium.com/@EthanRBrown/typescript-literal-types-and-human-readable-code-a50fdcf0474b
- author_url
- https://medium.com/@EthanRBrown
- status
- ok
- fetched_at
- 2026-07-23 19:09:44