#What's the right way to implement type guards that are checked to be exhaustive and accurate?

47 messages · Page 1 of 1 (latest)

twin sundialBOT
#
tickleme_pink#0

Preview:```ts
const env = {
dev: {name: "dev", data: 1},
prod: {name: "prod", data: 2},
} as const

type Env = typeof env[keyof typeof env]

const validEnv = new Set(
Object.values(env).map(x => x.name)
)

function envFromInput(e: unknown): e is Env {
if (
e != null &&
typeof e === "object" &&
"name" in e &&
typeof e.name === "string" &&
validEnv.has(e.name)
)
...```

dense forge
#

if you're just asking how to avoid the error in that playground, one way is to check for each name individually:

twin sundialBOT
#
mkantor#0

Preview:ts ... function envFromInput(e: unknown): e is Env { if ( e != null && typeof e === "object" && "name" in e && typeof e.name === "string" && (e.name === "dev" || e.name === "prod") ) { return env[e.name] === e } return false }

halcyon marlin
#

Suppose I add a new env in there, there will be no type checking error and it will fail at runtime

#

Also, I'd much rather use a set to lookup. But the set narrows down the type and .has doesn't expand the type.

dense forge
#

is validEnv something you already have/use in other ways? or is that only here for usage with this predicate?

#

actually i think i could use a little more context in general. how else is the env variable used too? in what situation do you end up with an unknown value that needs to match a specific value in a statically-known object?

halcyon marlin
#

It's just for this predicate. I'd rather be able to only have types for that.

#

Suppose you load it from a file as json.

dense forge
halcyon marlin
#

The use case now is that I'm writing io-ts codecs, it actually gets loaded as just the string dev or prod.

dense forge
#

yeah, that's what i was going to ask about ☝️

#

if you already know exactly what prod refers to, why not have the unknown input just be the string "prod" as a way to refer to the statically-known env value

#

in io-ts you're better off trying to derive the type from the codecs themselves. i can show an example; are you using the old Type APIs or the "experimental" Codec ones?

halcyon marlin
#

But I still get the same problem no? I have a list of strings that are acceptable, which are the names of all defined environments. I'd want to see if it matches any of them without hard coding them in a way that the check might be non-exhaustive.

dense forge
#

sounds like you just want a single source of truth, which can be the codecs. you can derive everything else from there

halcyon marlin
#

The actual envs are const-assertions

#

The codec just turns the string dev into the actual definition.

#

Just a decoder really, no need for encoder.

dense forge
#

argh, i can't figure out how to import io-ts/Codec (or io-ts/Decoder) in the TS playground. and the new stuff isn't re-exported from the root io-ts module. you happen to know of any way to get this to work?

twin sundialBOT
#
mkantor#0

Preview:ts import * as D from "io-ts/Decoder"

halcyon marlin
#

Here's an updated playground that might help:

twin sundialBOT
#
tickleme_pink#0

Preview:```ts
const dev = {name: "dev", data: 1} as const
const prod = {name: "prod", data: 2} as const
// I eventually add build, nothing fails to compile but envFromInput doesn't work for "build"
const build = {name: "build", data: 3} as const

type Environment =
| typeof dev
| typeof prod
| typeof build
...```

dense forge
#

well here's the kind of thing i was suggesting, even though the playground doesn't like these imports:

import * as E from 'fp-ts/Either'
import * as D from 'io-ts/Decoder'

const environmentNames = ['dev', 'prod'] as const

const environmentNameDecoder = D.literal(...environmentNames)
type EnvironmentName = D.TypeOf<typeof environmentNameDecoder>

const isEnvironmentName = (x: unknown): x is EnvironmentName => E.isRight(environmentNameDecoder.decode(x))

(i'm just focused on the name here because that's simpler)

#

with that setup there's only a single place to add a new environment like 'build', and then everything else is tied to it and will automatically start handling it

halcyon marlin
#

I'm not sure I'll be able to get all the definitions from io-ts though. What if you have different categories, like RuntimeEnv which is type RuntimeEnv = Exclude<Environment, Build>

dense forge
#

i'm getting a bit tripped up over this name vs actual full environment object thing. mind if we settle on that and then circle back to your question?

halcyon marlin
#

Yea sure

dense forge
#

in a perfect world (forgetting about your implementation), would the outside world be providing the entire environment, or just its name (which you can later expand into the full details)? from what you've shared so far the name seems simpler/better (since the environments seem very rigid/specific)—is that right?

halcyon marlin
#

Right, just the name and the rest is static. (Other env var would be provided for other things)

dense forge
#

hang on, i should be specific. i'll mock something up

#

one more question: in your original playground you do repeat the environment names twice (as the property key and in the value of the name property). is that acceptable? i know i said single source of truth but i think you're going to need to end up writing them at least twice to do what you want to do

#

i think i could write a type-level test to prove the two sets of names are aligned, if that helps

halcyon marlin
#

That was just a lookup table to make things easy.

#

I don't mind too much

#

I can refine it too, just trying to see what's the right approach to avoid modifications not being type checked.

#

(and I suppose keep more type safety in the type guard, since my solution works but has type casting)

dense forge
#

here's an example without any unsafeness:

import * as E from 'fp-ts/Either'
import * as D from 'io-ts/Decoder'

const environmentNames = [
  'dev',
  'prod',
  // 'build',
] as const
const environments = {
  dev: { data: 1 },
  prod: { data: 2 },
  // build: { data: 3 },
} as const satisfies Record<EnvironmentName, object>

type EnvironmentsByName = typeof environments
type Environment = EnvironmentsByName[keyof EnvironmentsByName]

// type Build = EnvironmentsByName['build']
// type RuntimeEnv = Exclude<Environment, Build>


const environmentNameDecoder = D.literal(...environmentNames)
type EnvironmentName = D.TypeOf<typeof environmentNameDecoder>

const isEnvironmentName = (x: unknown): x is EnvironmentName => E.isRight(environmentNameDecoder.decode(x))
#

you do have to write environment names twice, like i mentioned. but the type checker proves they're in alignment

halcyon marlin
#

satisfies Record<EnvironmentName, object> Not familiar with that.

dense forge
#

it's not strictly necessary here, just serving as an extra check. that check could be done in other ways too though

halcyon marlin
#

Yea I have to read about that, first time I encounter it. Looks useful

dense forge
#

it is pretty nice. probably one of my favorite new bits of syntax in a while

halcyon marlin
#

I'll test your code in a bit, doesn't work well in playground or codesandbox.

#

Thanks!