Encoders and Decoders in TypeScript

Encoding

Encoding in our context means converting a type to a string so we can save it to the web browser’s local storage. In soundly typed languages, this operation will never fail, but that is not true in JavaScript which TypeScript compiles to.

Caveat: Much of the code below is psuedo types and code. Please refer to the 3 “Final Full Examples” at the bottom of each section to see _actual_ working code you can use and safely reference.

Caveat: You’ll see if(error instanceof Error) example code. The instanceof keyword isn’t great for runtime type checking in all cases, so in the future use Error.isError(error) where applicable. At the time of this writing, the proposal is Stage 3, which means browsers intend to implement, and TypeScript will start to implement as well.

JSON.stringify

The JSON.stringify function can fail for a variety of reasons (circular references, BigInt), and has various nuances where some data types are converted to null, or are omitted entirely.

That means, encoding is not a safe operation. While not the true type, you can think of JSON.stringify having the following type:

stringify = (data:uknown) => string | neverCode language: JavaScript (javascript)

That means, it can either return a string, or throw an exception and never return anything. Exceptions aren’t good, thus we must wrap the unsafe operation JSON.stringify and return some other type indicating either a success with the encoding data, or error indicating why the encoding failed.

EncodeData Function Type

In TypeScript, that means the return type must be defined as some type of Result such as a Promise or Observable, because the encoding operation can possibly fail. Below, the encoder’s type gets some data, and returns a string if it works, else an error if it fails.

type encodeData = (data:Record<string, unknown>) => Promise<string>Code language: JavaScript (javascript)

Success example:

john = { name: 'John', age: 17 }
result = encodeData(john)
console.log(result.data) // '{ "name": "John", "age": 17 }'Code language: JavaScript (javascript)

Failure example:

azathoth = { name: 'A̸̰̘͖̔z̵̛̝̀̊͠a̷͖̓̊ṱ̴̯̾̍̓̕ḣ̵̖̘̺̙̇ǫ̸̲̐̓̉t̴̢̜̊͊͂ẖ̵͆͊̾͛', age: BigInt(9007199254740991) }
result = encodeData(azathoth)
console.log(result.error) // TypeError: Do not know how to serialize a BigIntCode language: JavaScript (javascript)

EncodeData Function Contents

To ensure the types above remain correct, we can simply wrap the JSON.stringify above in a try/catch.

try {
  const encodedString = JSON.stringify(data)
  return Ok(string)
} catch(error) {
  return Err(error.message)
}Code language: JavaScript (javascript)

EncodeData Can Return a String

If you’ve seen soundly typed languages like Elm, ReScript, Haskell, Scala, etc, you may have noticed many of their encoders do not fail. This is because the the types are sound, meaning it cannot go wrong going from one type to another. The developer is required to write the encoders by hand, however. There is no one size fits all function like JSON.stringify to magically convert your type to a string. This means our encodeData would have the changed type of:

type encodeData = (data:Record<string, unknown>) => stringCode language: JavaScript (javascript)

JavaScript has many mechanisms to encode more complex data types. The BigInt above that broke our original encoding, you can monkey patch BigInt.prototype.toJSON. For undefined and null, you can choose to manually include the property with a null value, or choose to omit it entirely, treating undefined and null as a missing value.

However, the correctness is hard to get correct with a Record<string, unknown> without library support given JavaScript’s numerous data types, and various options in encoding the data. Often data is encoded to be _decoded_, which means the developer will encode knowing they’ll want to decode the data a specific way later.

Narrowed Encoder

This means, we’ll often narrow our types to be more specific than Record, and then create custom encoders and decoders for them which is easier to create, and easier to verify they are correct in most cases.

Say we have an enum we want to encode:

enum CowRace {
  Cow = 'cow',
  NotACow = 'not a cow'
}Code language: JavaScript (javascript)

The type for the encoder would be:

type encodeCowRace = (cowRace:CowRace) => stringCode language: JavaScript (javascript)

The function implementation would look something like the following:

if(cowRace === CowRace.Cow) {
  return JSON.stringify('cow')
} else {
  return JSON.stringify('not a cow')
}Code language: JavaScript (javascript)

Much easier to unit test, verify it is correct with compiler support, and decode back from local storage.

Specific Encoders as Function Parameters

Now that we’ve established using type narrowing results in easier to encode types, and that those types will have an associated encode function, let’s look at ways to use them in saving data.

Let’s encode our CowRace enum above to localstorage. We have 3 types, 2 of which we’ve already covered; our CowRace enum:

enum CowRace {
  Cow = 'cow',
  NotACow = 'not a cow'
}Code language: JavaScript (javascript)

Our encoderCowRace function type:

type encodeCowRace = (cowRace:CowRace) => stringCode language: JavaScript (javascript)

And our new type, the saveCowRaceToLocalStorage function:

type saveCowRaceToLocalStorage =
  (cowRace:CowRace, encodeCowRace:EncodeCowRace) =>
 stringCode language: JavaScript (javascript)

The type takes a cowRace enum, and your encoder function, and returns a string. The string is just whatever it was encoded too. The function may look something this:

encodedString = encodeCowRace(cowRace)
localStorage.setItem('cowrace', encodedString)
return encodedStringCode language: JavaScript (javascript)

You’d invoke the saveCowRaceToLocalStorage like so:

result = saveCowRaceToLocalStorage(CowRace.Cow, encodeCowRace)
// result is "'cow'"Code language: JavaScript (javascript)

Generic Encoders as Function Parameters

The above uses a specific type and associated encoder function. What if you want a way to save to local storage that supports any type? In that case, you use a generic type parameter. Just like functions accept parameters, types accept parameters as well.

Let’s change our saveCowRaceToLocalStorage in 3 steps: update to accept a generic type, add a second parameter to accept any encoder, and finally add a return type. The generic implies “You can pass any type you want” which also means the developer passing the type must also create an encoder for it and pass it to us.

Step 1: Accept Generic Type Parameter

The first step is to change the name and 1st parameter type so we can save _anything_ to localstorage:

type saveAnythingLocalStorage<Type> = (data:Type) ...Code language: HTML, XML (xml)

That means now you can pass the cowRace parameter like before, but also the CowEnum type. Notice the cowRace is lowercase to be our enum value, and the CowRace type is uppercase to visually indicate a type:

saveAnythingLocalStorage<CowRace>(cowRace)Code language: HTML, XML (xml)

This also supports our Record<string, unknown>:

saveAnythingLocalStorage<Record<string, unknown>>(john)

Step 2: Accept Generic Encoder

The type parameter is a type. The generic encoder is also a type, more specifically a function type. We have to narrow down what the encoder actually returns, though. We’ll stick to string for now since most encoders will be converting their types to strings for use as JSON strings, in our case saving to local storage so we can read out later.

type saveAnythingLocalStorage<Type> =
  (
    data:Type,
    encoder:(convertMe:Type) => string
  ) ...Code language: JavaScript (javascript)

The function reads “Call saveAnythingLocalStorage and pass it the data you want to save, and the encoder function which converts it to a string.”

Using our existing CowRace encoder above, encodeCowRace, we can call that new function like so:

saveAnythingLocalStorage<CowRace>(cowRace, encodeCowRace)Code language: HTML, XML (xml)

We _could_ also do a generic one for JSON.stringify and our Record<string, unknown>, but that’s  not safe given TypeScript thinks JSON.stringify has a return value of string, but we know it’s actually string | never. However, I’ve put here anyway so you know how to do it. TypeScript _is_ a gradually typed language after all, so good to get something work first, then make the types strong as you refactor.

saveAnythingLocalStorage<Record<string, unknown>>(john, JSON.stringify)Code language: JavaScript (javascript)

Step 3: Return Value

The last step is the return value. Since all of the encoders we’ve written cannot fail, we’ll simply return their value, a string which is their encoded representation.

type saveAnythingLocalStorage<Type> =
  (
    data:Type,
    encoder:(convertMe:Type) => string
  ) => stringCode language: JavaScript (javascript)

The function implementation, regardless of inputs, looks like:

const encodedString = encoder(data)
return encodedStringCode language: JavaScript (javascript)

Final Non-Failing Encoder Result

Putting it all together, our final happy path code looks like:

enum CowRace {
  Cow = 'cow',
  NotACow = 'not a cow'
}

type Encoder = (convertMe:Type) => string

const encodeCowRace = (cowRace:CowRace):string => {
  if(cowRace === CowRace.Cow) {
    return JSON.stringify('cow')
  } else {
   return JSON.stringify('not a cow')
  }
}

const saveAnythingLocalStorage = <Type,>(data:Type, encoder:Encoder):string => {
  const encodedString = encoder(data)
  return encodedString
}

const cowRace = CowRace.Cow
const result = saveAnythingLocalStorage<CowRace>(cowRace, encodeCowRace)
// result is: 'cow'Code language: PHP (php)

Generic Encoders That Can Fail

In soundly typed languages, encoders cannot fail. In TypeScript, using JSON.stringify under the hood means they can fail. To make our encodeCowRace safer and the types more accurate, we can change the return value to some type of Either; a type indicating something can fail. The most common in TypeScript regardless of Browser or Node.js server is Promise, and for Angular an Observable. However, both don’t treat errors as values as well, so we’ll just make our own for now.

If it works, return the encoded string. If it fails, return the `Error` explaining why it failed:

type EncodeResult = string | ErrorCode language: JavaScript (javascript)

We’ll change the encoder that returns a string:

type EncodeCowRace = (cowRace:CowRace) => stringCode language: JavaScript (javascript)

To instead return our Result type:

type EncodeCowRace = (cowRace:CowRace) => EncodeResultCode language: JavaScript (javascript)

That means, the first part of our encodeCowRace function implementation is just wrapped with a try/catch:

try {
  if(cowRace === CowRace.Cow) {
    return JSON.stringify('cow')
  } else {
    return JSON.stringify('not a cow')
  }
...Code language: JavaScript (javascript)

The 2nd part, error is typed as unknown, so if it’s an Error, we’ll return that, else make a new Error and attempt to convert whatever to error was to a readable string inside it:

catch(error:unknown) {
  if(error instanceof Error) {
    return error
  } else {
    return new Error(`unknown encoding error: ${String(error)}`)
  }
}Code language: JavaScript (javascript)

That means our saveAnythingLocalStorage no longer “always succeeds”. So we’ll change it’s return type from a string…:

type saveAnythingLocalStorage<Type> =
  (
    data:Type,
    encoder:(convertMe:Type) => string
  ) => stringCode language: JavaScript (javascript)

To the EncodeResult instead:

type saveAnythingLocalStorage<Type> =
  (
    data:Type,
    encoder:(convertMe:Type) => string
  ) => EncodeResultCode language: JavaScript (javascript)

Now the types are correct, the function is safe, and the developer can pass in any types they want to safely encode.

Final Can-Fail Decoding

Our final encoding example where the encoding can fail below:

enum CowRace {
  Cow = 'cow',
  NotACow = 'not a cow'
}

type EncodeResult = string | Error

type EncoderCanFail = <Type>(convertMe:Type) => EncodeResult

const encodeCowRace = <CowRace,>(cowRace:CowRace):EncodeResult => {
  try {
    if(cowRace === CowRace.Cow) {
      return JSON.stringify('cow')
    } else {
      return JSON.stringify('not a cow')
    }
  } catch(error:unknown) {
    if(error instanceof Error) {
      return error
    } else {
      return new Error(`unknown encoding error: ${String(error)}`)
    }
  }
}

const saveAnythingLocalStorage2 = <Type,>(data:Type, encoder:EncoderCanFail ):EncodeResult => {
  const encodedStringOrError = encoder(data)
  return encodedStringOrError
}

// success is string
const cowRace = CowRace.Cow
const result = saveAnythingLocalStorage2<CowRace>(cowRace, encodeCowRace)
// result is: 'cow'

// failure is Error
type CowIsCool = { race: CowRace, age: BigInt }
const encodeCowIsCool = <CowIsCool,>(cool:CowIsCool):EncodeResult => {
  try {
    return JSON.stringify(cool)
  } catch(error) {
    if(error instanceof Error) {
      return error
    } else {
      return new Error(`unknown encoding error: ${String(error)}`)
    }
  }
}
const failCow = { race: CowRace.Cow, age: BigInt(9007199254740991) }
const resultBad = saveAnythingLocalStorage2<CowIsCool>(failCow, encodeCowIsCool)
console.log("resultBad:", resultBad)
// BigInt value can't be serialized in JSON Code language: PHP (php)

Decoding

Decoding works the same way as encoding, just in reverse. We give our decoder function a type we want to decode to, and a decoder to parse the string to our type.

Why not simply use JSON.parse and then cast the parsed result using as? A few reasons this is incorrect and dangerous:

  • JSON.parse can also throw an error
  • You can get an unknown return value, so you’ll have to type narrow to your type. (We’ll avoid doing type narrowing in this post and assume you’ll use something like Zod or ArkType heavily in your decoders).
  • as turns TypeScript type checking off, which is unsafe

Let’s first create a specific decoder, then we’ll make it generic just like we did for our encoder.

Specific Decoder

Our enum before is CowRace, so our decoder needs to convert a string to a CowRace. However, what if someone passes a string that is not "cow" or "not a cow" such as "bunny" or empty string? We have 2 choices. We can either assume anything that’s not "cow" is CowRace.NotACow, OR we can return an error.

It may be tempting to just use a default, but this makes it much harder to debug later when many downstream components and UI’s are getting default data, and you didn’t expect it to. We want to parse, don’t validate; meaning we want to parse our data, and if it doesn’t look correct, it should fail vs. “make an assumption that bites us later which it turns out the data is invalid and we have to backtrack to figure out where our code went wrong”.

So let’s type it correctly as a Result: either we got our data and it’s good, or we got something that is not an encoded CowRace enum.

type DecodeResult = CowRace | ErrorCode language: JavaScript (javascript)

Next up is to pass in our decoder, which takes a JSON string, parses it, and attempts to convert it to a CowRace enum.

type CowRaceDecoder = (jsonString:string) => DecodeResultCode language: JavaScript (javascript)

The internals look something like this:

const result = JSON.parse(jsonString)
if(result === 'cow') {
  return CowRace.Cow
} else if(result === 'not a cow') {
  return CowRace.NotACow
} else {
  return new Error(`Cannot decode ${result} to a CowRace.`)}
}Code language: JavaScript (javascript)

That’s the happy path assuming JSON.parse works. If that fails, such as when localStorage.getItem returns a null value or a malformed JSON string, then we’ll need to handle that unhappy path as well:

} catch(error:unknown) {
  if(error instanceof Error) {
    return error
  } else {
    return new Error(`Unknown decoding error: ${String(error)}`)
  }
}Code language: JavaScript (javascript)

Finally, our CowRace function to read out of local storage and decode it in a type safe way looks something like:

type readCowRaceFromLocalStorage = (decoder:CowRaceDecoder) => DecodeResultCode language: JavaScript (javascript)

The internals look something like this:

const readString:string | null = localStorage.getItem('cowrace')
if(readString !== null) {
  const decodeResult = decoder(readString)
  return decodeResult
} else {
  return new Error('No CowRace encoded data found in localstorage.')
}Code language: JavaScript (javascript)

Generic Decoder

The above is specific to decoding our CowRace enum, but what if we wanted our local storage decoder to be generic? There are 3 things to make dynamic:

  1. the key of where we read in local storage
  2. the decoder type has to be generic
  3. the decoder function needs to be updated

Let’s handle those 3 in order. The latter 2 should look familiar from the encoder exercise. We’ll cover the types first, then we’ll work on function implementation.

Step 1: Key

The key is just a string, so that type includes it as the first parameter:

type readAnythingFromLocalStorage = (key:string) => ...Code language: JavaScript (javascript)

That’d make your internals something like:

const readString:string | null = localStorage.getItem(key)Code language: JavaScript (javascript)

Step 2: Decoder

Next up is to pass our decoder. However, the return result is _too_ specific:

type DecodeResult = CowRace | ErrorCode language: JavaScript (javascript)

We need that result to return _any_ type. So let’s change that first via a type parameter:

// incorrect
type DecodeResult<Type> = Type | ErrorCode language: JavaScript (javascript)

HOWEVER, that may look right, but sadly, TypeScript unions “don’t know the difference” between the types they’re unifying if they’re objects like this. What’s to say you’re not passing an Error? Then what is type DecodeResult<Error> = Error | Error saying exactly? Yeah, I don’t know either.

So let’s whip out a discriminant so TypeScript knows the difference between _our_ generic type, and an Error.

// not quite correct
type DecodeResult<Type> = { tag: 'success', value: Type } | { tag: 'error', error: Error }Code language: JavaScript (javascript)

However, TypeScript may give you a compiler error when attempting to create those types like “Type ‘{ tag: “success”; value: CowRace; }’ is not assignable to type ‘DecodeResult<Type>’.” or “can’t assign Type ‘{ tag: “success”; value: CowRace; }’ is not assignable to type ‘DecodeResult<Type>’. to an Error”. TypeScript needs help, even with discriminants (our tag) to identify a type. You can’t always just make a local variable and type it. Creating the types through functions really helps TypeScript:

// almost correct
type DecodeResult<Type> = { tag: 'success', value: Type } | { tag: 'error', error: Error }

const DecodeSuccess = <Type,>(value:Type):{ tag: 'success', value: Type } =>
    ({ tag: 'success', value })

const DecodeError = (error:Error):{ tag: 'error', error: Error } =>
    ({ tag: 'error', error })Code language: PHP (php)

… however, those copy pasta’d anonymous types everywhere are hard to read. You can DRY types just like you can DRY code. You do this by naming your types, just like how you name your variables:

// correct
type DecodeResult<Type> = DecodeSuccess<Type> | DecodeError
type DecodeSuccess<Type> = { tag: 'success', value: Type }
type DecodeError = { tag: 'error', error: Error }

const DecodeSuccess = <Type,>(value:Type):DecodeSuccess<Type> =>
    ({ tag: 'success', value })

const DecodeError = (error:Error):DecodeError =>
    ({ tag: 'error', error })Code language: PHP (php)

We’ll now make the decoder more generic by including that new generic type parameter:

type Decoder<Type> = (jsonString:string) => DecodeResult<Type>Code language: HTML, XML (xml)

You can read that as “If I give you a JSON string, you’ll either give me the type I’m expecting back, or an Error”.

Step 3: Return Value

Now that we’ve got our return type setup, and the decoder type is now generic as well, let’s do the same for the read from local storage function’s 2nd parameter:

type readAnythingFromLocalStorage = <Type,>(
key:string,
decoder:Decoder<Type,>
) => ...Code language: HTML, XML (xml)

And the return value:

type readAnythingFromLocalStorage = <Type,>(
key:string,
decoder:Decoder<Type,>
):DecodeResult<Type>Code language: HTML, XML (xml)

Step 4: Function Implementations

We have 2 functions to write: our CowRace decoder, and our generic “read anything from localStorage”.

While our CowRace decoder is returning a specific type, it’s still using the generic type we defined above. The signature looks something like:

const decodeCowRace = (json:string):DecodeResult<CowRace> => {Code language: PHP (php)

Notice how we specify CowRace in the DecodeResult‘s first type parameter; it can be generic, so we’re like “Cool, we’ll return a DecodeResult with a CowRace inside it”.

Now let’s parse our Enum from a string. Since we cannot gurentee the string read from an external localStorage is _our_ only 2 available enum strings, and JSON.parse are both dangerous operations, we’ll wrap in a try/catch:

try {
    const value = JSON.parse(json)
    if(value === 'cow') {
        return DecodeSuccess<CowRace>(CowRace.Cow)
    } else if(value === 'not a cow') {
        return DecodeSuccess<CowRace>(CowRace.NotACow)
    } else {
        return DecodeError(new Error(`Cannot decode ${result} to a CowRace.`))
    }
}Code language: JavaScript (javascript)

That’ll handle getting our data,but if the JSON.parse throws, let’s handle the error:

} catch(error) {
  if(error instanceof Error) {
    return DecodeError(error)
  } else {
    return DecodeError(new Error(`Unknown decode error: ${String(error)}`))
  }
}Code language: JavaScript (javascript)

That handles our decoder. Now let’s create the generic readAnythingFromLocalStorage:

const encodedString = localStorage.getItem(key)
if(encodedString !== null) {
  return decoder(encodedString)
} else {
  return DecodeError(new Error('Failed to find key in local storage'))Code language: JavaScript (javascript)

Final Decoder Code

The final code for decoder is as follows:

enum CowRace {
  Cow = 'cow',
  NotACow = 'not a cow'
}

type DecodeResult<Type> = DecodeSuccess<Type> | DecodeError
type DecodeSuccess<Type> = { tag: 'success', value: Type }
type DecodeError = { tag: 'error', error: Error }

const DecodeSuccess = <Type,>(value:Type):DecodeSuccess<Type> =>
    ({ tag: 'success', value })

const DecodeError = (error:Error):DecodeError =>
    ({ tag: 'error', error })

type Decoder<Type> = (jsonString:string) => DecodeResult<Type>

const decodeCowRace = (json:string):DecodeResult<CowRace> => {
  try {
    const value = JSON.parse(json)
    if(value === 'cow') {
        return DecodeSuccess<CowRace>(CowRace.Cow)
    } else if(value === 'not a cow') {
        return DecodeSuccess<CowRace>(CowRace.NotACow)
    } else {
        return DecodeError(new Error(`Cannot decode ${result} to a CowRace.`))
    }
  } catch(error) {
    if(error instanceof Error) {
      return DecodeError(error)
    } else {
      return DecodeError(new Error(`Unknown decode error: ${String(error)}`))
    }
  }
}

const readAnythingFromLocalStorage = <Type,>(
key:string,
decoder:Decoder<Type>
):DecodeResult<Type> => {
  const encodedString = localStorage.getItem(key)
  if(encodedString !== null) {
    return decoder(encodedString)
  } else {
    return DecodeError(new Error('Failed to find key in local storage'))
  }
}

localStorage.setItem('cowrace', JSON.stringify('cow'))
const result = readAnythingFromLocalStorage<CowRace>('cowrace',decodeCowRace)
// 'Cow'

localStorage.clear()
const result2 = readAnythingFromLocalStorage<CowRace>('cowrace',decodeCowRace)
// Error: Failed to find key in local storageCode language: PHP (php)

Note: < Type, > vs < Type >

You have noticed in some of the types above, we used a <Type,> instead of a <Type>. For old school JavaScript functions, you can use the type parameters like so:

function<Type> nameOfFunction(...)Code language: JavaScript (javascript)

However, for Arrow functions, that syntax currently fails to parse in TypeScript.

type func<Type> = () => void // works
function <Type>() {} // works
func = <Type>() => undefined // fails
func = <Type,>() => undefined // worksCode language: PHP (php)


The parameters and return value are fine, it’s just the initial type parameter part in the front. There are a few options such as having the 1st parameter extends unknown, but mixing inheritance and type parameters doesn’t make much since and is a lot more to read. Types are hard enough to read, and TypeScript types are quite verbose, so anything you can do to shrink it helps.

Conclusions

As you can see, encoding and decoding in TypeScript can make your code safer, reduce the amount of type narrowing you need to do, especially if you use Zod. For consumers of your code and api’s, it gives them the flexibility of utilizing your API’s while providing their own types which includes their own encoders and decoders in a type-safe way. TypeScript can safely ensure all erros are typesafe, and those error scenarios are handled in developers who use your code.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *