Xenoamorphous

I don't like zod. I want to define my types, not write schemas. And I don't like that then I have to use the types derived from those schemas rather than types I've defined myself directly.

So I just define my types and then use typescript-json-schema or similar to build a JSON Schema at build time (i.e. from an npm script) which then I use to validate input using ajv.

The only thing I do on top of that is to use annotations like "@minimum 0" (or, in the email example, "@format email") where the base types are not enough, but those simply go inside comments.

So the compiled package only has ajv as runtime dependency (which you're likely to have anyway, as it's everywhere), you're just defining regular types with some annotations on top and use a dev dependency to build you the JSON Schema. And as popular as zod is, I think JSON Schema is more of a standard and likely to stay with us longer.

I also reference those generated JSON Schemas from my OpenAPI definition, as a bonus.

show comments
Altern4tiveAcc

Zod is by far the most ergonomic way to express those ideas in TypeScript these days. I miss it when writing code in other languages.

The friction with the rest of the ecosystem is real, though. Most code out there expects you to handle errors with exceptions.

I get the impression that polymorphic return types could get in the way of JSC/V8/SpiderMonkey's JIT, but I haven't measured it and I'm not sure of the actual impact on hot and cold paths. Same for all the allocations caused by custom Option<T>/Result<T,E> implementations.

I think using Zod at the edge (with branded types and whatnot), while keeping return types as T/Promise<T> to keep a sane relationship with the ecosystem is a good middle ground.

show comments
ramon156

The author found out about the square holes in round peg situation with TS. Functions can implicitly error, and there's no annotation that's enforced to tell you that it might error. FP solves this with Result/Option, but this doesn't fit in TS. Effect is there to find a solution but will fail.

Zod is the acceptable middleground in my opinion. Zod will allow you to throw a schema against an object and it'll tell you "yes the result fits your schema". This is fine for most projects.

If you want to go zero-dependency, you can see how far you can get with TS's type system. Branded types are kinda cool. NewTypes are also cool, but also high maintenance. Unless you're building a library that millions depend on, it's probably not worth it.

show comments
lumpysnake

We should make authors disclose how much AI was used to write an article. This reeks of Opus 4.8.

show comments
exceptione

It is nice the author mentioned F#, because if you want to target the browser (or any JavaScript runtime), you can do from F# directly from fable (https://fable.io). This allows you to program by default in a type safe manner without having to play tricks to circumvent the limits of structural typing.

show comments
robertlagrant

This feels right, and I also have never done it (or had the guts to get others to do it).

The reason I've not is - say there's an optional field. Currently we call that null, probably, and check each time if it's there or not. I could instead make a type, like User and UserWithPhoneNumber. Should we be making types for each combination of present/absent fields? That can't be right.

The classic answer is to move the logic inside the domain object, or have a helper function outside the object, so you aren't constantly checking for field presence/absence, but are instead writing the logic once and calling some code.

I'm not sure in practice types can help with this. But I'd love to be proven wrong.

show comments
sigbottle

Parse, don't validate is one way of building constraints. The issue is it feeds into a tree-based view of constraints. However it does yield the philosophy of "constraints by construction".

Another is making a set of "linearly independent" configurations - except in practice it never is, is it? Has anyone actually ever had a clean CI Matrix that didn't have weird hidden edge cases, for example?

Functional programming really wants to emphasize the notion of pure functions, which have modularity and independence built in. But there are perf issues and in practice, you don't really escape the issues of "how to design constraints". Sure, you don't need inheritance and OOP and all of that, but you can easily have a tree-based view of constraints and ontology in FP as well.

(Incidentally, my view of the issue with something like Carnap's logical frameworks is that they are so general and flexible that they fail to capture anything operationally useful; yes, I know that isn't always philosophy's goal but I view the same with a lot of purported theories of everything today)

Are there any other philosophies in software that have certain distinct wins versus losses when it comes both to the organization & encoding of your constraints, and coming up with them? Tree-based hierarchal decomposition and linearly independent axes in a space are two go-to things for me.

I suppose you could design a state machine, but that requires understanding all the semantics upfront, encoding them once, and hoping that requirement changes don't mess you up.

I have seen poset-based solutions as well (actually, I think "monotonic" distributed architectures are based around this approach) but that obviously requires a very specific type of problem domain.

----

There are also some very common memes from physics-swes: such as how information cancels out over "long distances" and therefore certain kinds of abstractions are good; attractor states in idea space; or even people loving the idea of symmetry (which, granted - in physics, is truly a beautiful approach, but does not seem to generalize well to generic software engineering). But those are a bit too high level to put into a concrete software plan. Still interesting though.

rzmmm

Is there benefit of using this branded type over just encapsulating the raw string in a private variable in closure or class? This feels a bit like forced nominal typing. The Email type doesn't have to be a string, it can be encapsulated so that invalid Emails are not representable.

show comments
gherkinnn

I found that having clean models and parsing your data using Zod religiously at the application boundary (requests, URL, DB, env) gets you 80% of the way without fighting the language.

The stray email: string causing trouble is fine and is less work than self-imposed constraints that will be worked around by others.

throwaw12

I personally love the idea and concept, but struggle to apply to real projects.

Suppose I have a User with some attributes like birthday, email and whether they have been verified.

in common codebase, you can see `if (user.verified_at != null)` or something along the lines, in case of parsed code I do feel like I should have types for each of them (or interfaces):

    - UserWithBirthday
    - VerifiedUser, UnverifiedUser
    - UserWithEmail, UserWithoutEmail
(and imagine having a method which accepts user with birthday and email to send an email day before their birthday, would you create UserWithBirthdayAndEmail type?)

it feels like it is going to bloat the interface space, how do you tackle this problem?

show comments
toolslive

You don't have to use TypeScript if you don't want to: you can compile Haskell, Ocaml, Rust, F#, ... to javascript. This is quite efficient, especially if your backend is already in one of those languages. It saves you from creating the same abstraction twice in different languages.

show comments
ivolimmen

One of the pillars of Domain Driven Design. I love working on a pure DDD application but I do not often convince my team (I am a constant) that this is the best way ...

show comments
somat

"TypeScript is structurally typed, which means two types with the same shape are the same type. string is string is string"

I don't speak typescript so am probably missing something obvious. but. why would you parse an email(or anything really) into a string? (or string equivalent) When parsed it will end up as a specific email object, that is, something closer to a C struct. What is the articles dance doing?

show comments
hankbond

As a new TypeScript user these are concepts that have greatly helped me simplify my code and improve reliability discrete of testing. Many LLMs guide in this direction if you loosely ask them, but having a concise post like this with the what and the why is fantastic as reference material. The suggestion to use Separation and a Linter rule is something I'm going to immediately look into for my current project. Great post!

wwalexander

This is just validation that is using the type system to indicate the validation has already occurred. I think the real point of “parse, don’t validate” is to make the type system give you structural guarantees that couldn’t exist otherwise (e.g. always having a first/last element in the NonEmpty example from the original article). If you’re just branding the types as “parsed” (in reality, simply validated) you still have to know that the invariants you care about hold when using the “parsed” type (e.g. splitting the email type using “@“ will always yield 2 elements), instead of the structure of the type holding that info inherently (e.g. struct Email { name: String, host: String }).

show comments
conartist6

Don't forget to freeze the objects

philipwhiuk

The problem with encoding stuff in type systems is where you stop.

ramses0

Meta: in addition to upvotes and downvotes, we almost need a slop/not-slop slider.

This one barely scrapes by at what feels like 30-40% "slop": "honestly", "the one thing", etc...

...but I did learn something about "Brand" types, and have personally tried to do more of "parse don't validate" in my own code.

Recently I did this similar trick for `exec( ValidExecutable(...) )` [python], where it required tagging/washing through a private function/variable to "get" the private bit.

All the scanners tend to light up when they see "exec" at all (eg: `exec( "pandoc" )` for PDF generation), but I needed to hard code a few "expected" pandoc locations so the imaginary hackers couldn't shadow "pandoc" on a path location they controlled.

whilenot-dev

  default: {
    const _exhaustive: never = result;
    return _exhaustive;
  }
...is not how people should implement an exhaustiveness check ever! An exhaustiveness check exhausts your knowledge about the world, it should throw an exception at runtime. Just returning the non-matched case is a recipe for disaster. Do this instead:

  default:
    ((value: never) => { throw new Error(`Missing case for value: ${value}`); })(result);
show comments
roywiggins

ai; dr, unfortunately

simonreiff

I always felt a little duped whenever I tried coding in TypeScript. You get zero runtime type safety guarantees, plus it's often harder to tell in TypeScript whether the transpilation will result in an efficient and performant implementation. Maybe the worst thing is that if you have two objects, one called EmailAddress and one called UnrelatedThing, but both have a UUID as the first thing and a string as the second thing, and now you create an object at runtime that is called TotallyUnrelatedThing that has a UUID followed by a string, the runtime sees EmailAddress, UnrelatedThing, and TotallyUnrelatedThing as being structurally identical and in fact they are all "compatible" under TS at runtime, which is usually the exact opposite of what one would expect. Now in other languages you can get some additional guarantees like in C# at the cost of more ceremony and boilerplate to establish all your abstract primitives and layers.

My own approach is mostly to prefer JS and JSON objects with helper chains including validators and constructor/builder and parser utilities. Get the age from the user and get the domain from the email address and don't be surprised by the type, because everything is an object, and don't be surprised that you need to validate and parse, but expect to do so always. Do it in as modular and reusable a pattern as makes sense, which often isn't exactly the same for every scenario, but that's OK. Speaking of which, am I the only one who thinks it's usually more of a hassle than it's worth to define a universal EmailAddress for all times and places? Often the conflict happens because even if I try to do so, I usually am using one vendor as an IdP and a different vendor for transactional emails (even if I use the same cloud provider say for both). They each probably have different robust regex implementations to check whether something is truly an email address. I then still need authEmailAddrees and billingEmailAddress objects to pass to each, respectively, but there is no enforcement or requirement to instantiate an interface that contains an enforceable contract in TypeScript, so remind me why I am bothering to say these things are both email addresses? It just always feels like the worst of all worlds when I work in TypeScript, kind of a "rules for thee but not for me" situation. I have to follow typing, but TypeScript doesn't quite have to do so. In particular it always feels like I still have to enforce a lot more validation at the API layer than should be required, without any feeling that I can trust an EmailAddress to instantiate an IEmailAddress interface or that AuthEmailAddress and BillingEmailAddress inherit from the base EmailAddress or that those structures are guaranteed to persist at runtime and that a TotallyUnrelatedThing that just so happens to have a UUID plus a string but isn't strictly instantiating the email class will never accidentally end up populating an email field, which is kind of a concern. (By the way I think the hardcore email address validation really ought to be handled by the upstream provider anyway. I just do some minimal checks on length and presence of dots and at-symbols but don't bother trying to implement a full regex compendium of all email possibilities since these details frankly conflict frequently enough at the edges that I would rather let my IdP and email providers decide for themselves if they truly have an acceptable email input, and handle the failure loudly and up front, rather than try to do all the gatekeeping myself.)