110

I have a simple union type of string literals and need to check it's validity because of FFI calls to "normal" Javascript. Is there a way to ensure that a certain variable is an instance of any of those literal strings at runtime? Something along the lines of

type MyStrings = "A" | "B" | "C";
MyStrings.isAssignable("A"); // true
MyStrings.isAssignable("D"); // false

11 Answers 11

95

As of Typescript 3.8.3 there isn't a clear best practice around this. There appear to be three solutions that don't depend on external libraries. In all cases you will need to store the strings in an object that is available at runtime (e.g. an array).

For these examples, assume we need a function to verify at runtime whether a string is any of the canonical sheep names, which we all know to be Capn Frisky, Mr. Snugs, Lambchop. Here are three ways to do this in a way that the Typescript compiler will understand.

1: Type Assertion (Easier)

Take your helmet off, verify the type yourself, and use an assertion.

const sheepNames = ['Capn Frisky', 'Mr. Snugs', 'Lambchop'] as const;
type SheepName = typeof sheepNames[number]; // "Capn Frisky" | "Mr. Snugs" | "Lambchop"

// This string will be read at runtime: the TS compiler can't know if it's a SheepName.
const unsafeJson = '"Capn Frisky"';

/**
 * Return a valid SheepName from a JSON-encoded string or throw.
 */
function parseSheepName(jsonString: string): SheepName {
    const maybeSheepName: unknown = JSON.parse(jsonString);
    // This if statement verifies that `maybeSheepName` is in `sheepNames` so
    // we can feel good about using a type assertion below.
    if (typeof maybeSheepName === 'string' && sheepNames.includes(maybeSheepName)) {
        return (maybeSheepName as SheepName); // type assertion satisfies compiler
    }
    throw new Error('That is not a sheep name.');
}

const definitelySheepName = parseSheepName(unsafeJson);

PRO: Simple, easy to understand.

CON: Fragile. Typescript is just taking your word for it that you have adequately verified maybeSheepName. If you accidentally remove the check, Typescript won't protect you from yourself.

2: Custom Type Guards (More Reusable)

This is a fancier, more generic version of the type assertion above, but it's still just a type assertion.

const sheepNames = ['Capn Frisky', 'Mr. Snugs', 'Lambchop'] as const;
type SheepName = typeof sheepNames[number];

const unsafeJson = '"Capn Frisky"';

/**
 * Define a custom type guard to assert whether an unknown object is a SheepName.
 */
function isSheepName(maybeSheepName: unknown): maybeSheepName is SheepName {
    return typeof maybeSheepName === 'string' && sheepNames.includes(maybeSheepName);
}

/**
 * Return a valid SheepName from a JSON-encoded string or throw.
 */
function parseSheepName(jsonString: string): SheepName {
    const maybeSheepName: unknown = JSON.parse(jsonString);
    if (isSheepName(maybeSheepName)) {
        // Our custom type guard asserts that this is a SheepName so TS is happy.
        return (maybeSheepName as SheepName);
    }
    throw new Error('That is not a sheep name.');
}

const definitelySheepName = parseSheepName(unsafeJson);

PRO: More reusable, marginally less fragile, arguably more readable.

CON: Typescript is still just taking your word for it. Seems like a lot of code for something so simple.

3: Use Array.find (Safest, Recommended)

This doesn't require type assertions, in case you (like me) don't trust yourself.

const sheepNames = ['Capn Frisky', 'Mr. Snugs', 'Lambchop'] as const;
type SheepName = typeof sheepNames[number];

const unsafeJson = '"Capn Frisky"';

/**
 * Return a valid SheepName from a JSON-encoded string or throw.
 */
function parseSheepName(jsonString: string): SheepName {
    const maybeSheepName: unknown = JSON.parse(jsonString);
    const sheepName = sheepNames.find((validName) => validName === maybeSheepName);
    if (sheepName) {
        // `sheepName` comes from the list of `sheepNames` so the compiler is happy.
        return sheepName;
    }
    throw new Error('That is not a sheep name.');
}

const definitelySheepName = parseSheepName(unsafeJson);

PRO: Doesn't require type assertions, the compiler is still doing all the validation. That's important to me, so I prefer this solution.

CON: It looks kinda weird. It's harder to optimize for performance.


So that's it. You can reasonably choose any of these strategies, or go with a 3rd party library that others have recommended.

Sticklers will correctly point out that using an array here is inefficient. You can optimize these solutions by casting the sheepNames array to a set for O(1) lookups. Worth it if you're dealing with thousands of potential sheep names (or whatever).

Sign up to request clarification or add additional context in comments.

8 Comments

Array.prototype.indexOf() in recent node & some browsers appears to have O(1) lookups
The type guard in the second example doesn't seem to actually work due to the usage of in which expects an index or property name. Using Array.prototype.includes seems to work though.
@JameelA. yes sorry I've been meaning to update, fixed
Was testing second example and get Argument of type 'string' is not assignable to parameter of type '"Capn Frisky", "Mr. Snugs", "Lambchop"'. When I remove the as const from sheepNames-array, it works though. Any ideas what I'm missing?
@Hans, @bitconfused Keep the const and cast the input to SheepName sheepNames.includes(maybeSheepName as SheepName);
|
37

Since Typescript 2.1, you can do it the other way around with the keyof operator.

The idea is as follows. Since string literal type information isn't available in runtime, you will define a plain object with keys as your strings literals, and then make a type of the keys of that object.

As follows:

// Values of this dictionary are irrelevant
const myStrings = {
  A: "",
  B: ""
}

type MyStrings = keyof typeof myStrings;

isMyStrings(x: string): x is MyStrings {
  return myStrings.hasOwnProperty(x);
}

const a: string = "A";
if(isMyStrings(a)){
  // ... Use a as if it were typed MyString from assignment within this block: the TypeScript compiler trusts our duck typing!
}

2 Comments

If creating an object with empty values makes you cringe, you can also use an array or a set, then check x in myStrings or myStrings.has(x) respectively.
@jtschoonhoven the x in myStrings will actually not work, using the in operator on arrays will give you unexpected results. Check 'x' in [] to see what I mean … myStrings.includes(x) would be one way to do it, or the good ol' myStrings.indexOf(x) > -1. Also, the type needs to read type MyStrings = typeof myStrings[number] if you go for the array solution (as you have already noted in your answer).
24

If you have several string union definitions in your program that you'd like to be able to check at runtime, you can use a generic StringUnion function to generate their static types and type-checking methods together.

Generic Supporting Function

// TypeScript will infer a string union type from the literal values passed to
// this function. Without `extends string`, it would instead generalize them
// to the common string type. 
export const StringUnion = <UnionType extends string>(...values: UnionType[]) => {
  Object.freeze(values);
  const valueSet: Set<string> = new Set(values);

  const guard = (value: string): value is UnionType => {
    return valueSet.has(value);
  };

  const check = (value: string): UnionType => {
    if (!guard(value)) {
      const actual = JSON.stringify(value);
      const expected = values.map(s => JSON.stringify(s)).join(' | ');
      throw new TypeError(`Value '${actual}' is not assignable to type '${expected}'.`);
    }
    return value;
  };

  const unionNamespace = {guard, check, values};
  return Object.freeze(unionNamespace as typeof unionNamespace & {type: UnionType});
};

Example Definition

We also need a line of boilerplate to extract the generated type and merge its definition with its namespace object. If this definition is exported and imported into another module, they will get the merged definition automatically; consumers won't need to re-extract the type themselves.

const Race = StringUnion(
  "orc",
  "human",
  "night elf",
  "undead",
);
type Race = typeof Race.type;

Example Use

At compile-time, the Race type works the same as if we'd defined a string union normally with "orc" | "human" | "night elf" | "undead". We also have a .guard(...) function that returns whether or not a value is a member of the union and may be used as a type guard, and a .check(...) function that returns the passed value if it's valid or else throws a TypeError.

let r: Race;
const zerg = "zerg";

// Compile-time error:
// error TS2322: Type '"zerg"' is not assignable to type '"orc" | "human" | "night elf" | "undead"'.
r = zerg;

// Run-time error:
// TypeError: Value '"zerg"' is not assignable to type '"orc" | "human" | "night elf" | "undead"'.
r = Race.check(zerg);

// Not executed:
if (Race.guard(zerg)) {
  r = zerg;
}

A More General Solution: runtypes

This approach is based on the runtypes library, which provides similar functions for defining almost any type in TypeScript and getting run-time type checkers automatically. It would be a little more verbose for this specific case, but consider checking it out if you need something more flexible.

Example Definition

import {Union, Literal, Static} from 'runtypes';

const Race = Union(
  Literal('orc'),
  Literal('human'),
  Literal('night elf'),
  Literal('undead'),
);
type Race = Static<typeof Race>;

The example use would be the same as above.

Comments

10

You can use enum, and then check if string in Enum

export enum Decisions {
    approve = 'approve',
    reject = 'reject'
}

export type DecisionsTypeUnion =
    Decisions.approve |
    Decisions.reject;

if (decision in Decisions) {
  // valid
}

2 Comments

The issue here is that in the valid block, the decision type is not constrained by the language service.
Indeed. The Typescript compiler doesn't understand that decision is of type Decisions in the valid block. You would need to add a type assertion.
8

Use "array first" solution, create string literals and use Array.includes() at the same time:

const MyStringsArray = ["A", "B", "C"] as const;
MyStringsArray.includes("A" as any); // true
MyStringsArray.includes("D" as any); // false

type MyStrings = typeof MyStringsArray[number];
let test: MyStrings;

test = "A"; // OK
test = "D"; // compile error

Comments

5

using type is just Type Aliasing and it will not be present in the compiled javascript code, because of that you can not really do:

MyStrings.isAssignable("A");

What you can do with it:

type MyStrings = "A" | "B" | "C";

let myString: MyStrings = getString();
switch (myString) {
    case "A":
        ...
        break;

    case "B":
        ...
        break;

    case "C":
        ...
        break;

    default:
        throw new Error("can only receive A, B or C")
}

As for you question about isAssignable, you can:

function isAssignable(str: MyStrings): boolean {
    return str === "A" || str === "B" || str === "C";
}

3 Comments

This works, but breaks as soon as the MyStrings is extended without updating the corresponding isAssignable function. I was hoping for something with less manual burden.
There's not much you can do about it. As I wrote, the type part is strictly typescript and that information is lost in the generated js file. You can use an enum instead of that type.
If you're willing to put in the effort, you can write a tool to generate the TS code that defines the type AND the TS code that does the runtime type check from some other source of data. I've worked on code where a REST API was written in C# and we compiled the C# data contracts into TS, both a type definition as well as an runtime introspectable object literal describing the type. Understandably, all of that is outside of the scope of TS.
3

I have taken the approach of making a new object type from the union type and creating a dummy instance of the object type. Then the checking of string type can be done using type guards.

A nice thing about this is every time a newer type is added/removed to union, TS compiler will complain to update the object as well.

type MyStrings = "A" | "B" | "C";
type MyStringsObjectType = {
   [key in MyStrings ] : any
}
export const myStringsDummyObject : MyStringsObjectType = {
   A : "",
   B : "",
   C : "",
}
export const isAssignable = (type: string):type is MyStrings => {
   return (type in myStringsDummyObject)
}

Usage :

if(isAssignable("A")){  //true
   
}

if(isAssignable("D")){  //false
   
}

Comments

2

You cannot call a method on a type, because types don't exist in runtime

MyStrings.isAssignable("A"); // Won't work — `MyStrings` is a string literal

Instead, create executable JavaScript code that will validate your input. It's programmer's responsibility to ensure the function does its job properly.

function isMyString(candidate: string): candidate is MyStrings {
  return ["A", "B", "C"].includes(candidate);
}

Update

As suggested by @jtschoonhoven, we can create en exhaustive type guard that will check if any string is one of MyStrings.

First, create a function called enumerate that will make sure all members of the MyStrings union are used. It should break when the union is expanded in the future, urging you to update the type guard.

type ValueOf<T> = T[keyof T];

type IncludesEvery<T, U extends T[]> =
  T extends ValueOf<U>
    ? true
    : false;

type WhenIncludesEvery<T, U extends T[]> =
  IncludesEvery<T, U> extends true
    ? U
    : never;

export const enumerate = <T>() =>
  <U extends T[]>(...elements: WhenIncludesEvery<T, U>): U => elements;

The new-and-improved type guard:

function isMyString(candidate: string): candidate is MyStrings {
  const valid = enumerate<MyStrings>()('A', 'B', 'C');

  return valid.some(value => candidate === value);
}

7 Comments

Not sure why this is downvoted, this is true and provides a working solution. Though I would strongly recommend adding a check in the isMyString function to verify that ["A", "B", "C"] conforms to MyStrings or else the two will diverge dangerously.
Good suggestion. Updated my answer.
Thanks! Potentially even easier: in isMyString define const myStrings: Array<MyStrings> = ['A', 'B', 'C']; then just check that myStrings.includes((candidate as MyStrings));. That way you don't need to define enumerate.
I could, but then it will not be feature-ready. For example, if I add another string to MyStrings, say type MyStrings = "A" | "B" | "C" | "D", the type guard would return a false negative.
Well if myStrings is typed as Array<MyStrings> then the compiler will throw an error if you update the MyStrings type without updating the matching myStrings values. So it should be safe.
|
1

Based on @jtschoonhoven 's safest solution one can write generic factories for generating a parsing or validation function:

const parseUnionFactory = <RawType, T extends RawType>(values: readonly T[]): ((raw: RawType) => T | null) => {
   return (raw: RawType): T => {
       const found = values.find((test) => test === raw)
       if (found) {
           return found
       }
       throw new InvalidUnionValueError(values, raw)
    }
}

In use:

const sheepNames = ['Capn Frisky', 'Mr. Snugs', 'Lambchop'] as const
type SheepName = typeof sheepNames[number]

const parseSheepName = parseUnionFactory(sheepNames)
let imaSheep: SheepName = parseSheepName('Lampchop') // Valid
let thisllThrow: SheepName = parseSheepName('Donatello') // Will throw error

repl example

The weakness here is ensuring our type is kept consistent with how parseUnionFactory builds from our array of values.

Comments

1

This is my suggestion:

const myFirstStrings = ["A", "B", "C"] as const;
type MyFirst = typeof myFirstStrings[number];

const mySecondStrings =  ["D", "E", "F"] as const;
type MySecond = typeof mySecondStrings[number];

type MyStrings = MyFirst | MySecond;

const myFirstChecker: Set<string> = new Set(myFirstStrings);

function isFirst(name: MyStrings): name is MyFirst {
  return myFirstChecker.has(name);
}

This solution is more performant than using Array.find as suggested in other answers

3 Comments

I think you could optimise that by making isFirst a type predicate.
Why not? It doesn't change the return type, it only augments it for the type system.
🤔 I misunderstood the “type guard”. I will give it a try later and if it’s working as intended, I will update the answer. Thanks for the comment
1

I also had a similar requirement where I have to assert all allowed query parameters and at the same type wanted to have type guards with the Union type of all allowed params

So basically following are my requirement

  1. Method should be generic and can be used with APIs with different sets of allowed parameters. Like in one API I can enable, online|offline while in another case I may allow full|partial
  2. It should throw an exception in case of a mismatch in parameters. Since I am using this in the Express app, all APIs parameters should be validated in runtime
  3. I also like to follow DRY and don't want to have conditions where my allowed params can vary across implementation
  4. Obviously no dependency on external library 🙂

My solution satisfies all the above conditions

export function assertUnion<T extends string>(
    param: string,
    allowedValues: ReadonlyArray<T>
): param is T {
    if (!allowedValues.includes(param as T)) {
        throw new Error("Wrong value");
    }
    return true;
}

Usage can be like below

if (
    !assertUnion<"online" | "offline">(param, ["online", "offline"])
) {
    return;
}
console.log(param) // type of param will be "online" | "offline"

Here it may seem that we are defining the allowed types twice once as a Union type and once in an array but due to the definition of utility you can not pass any extra param in the array so basically your Union type definition will be the single source of truth

I really like to somehow use param is typeof allowedValues[number] but Typescript const assestion only works with the array literal and not with the array parameter 😞

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.