Type safety can go a long way with TypeScript π
The power of TypeScript never stops when you push it to full type safety As long as a type can be checked, TypeScript can make it type safe π
So far in fact that the actual internal implementation must fallback to as any
, often.
That's what's behind a full type safe API π
Type safe, but how far?
Type safety is an additional check that prevents a developer to misuse your API.
If you go the the extreme, you can prevent all (expected) runtime error with just types:
type ValidSlug = string & Brand.Brand<"ValidSlug">;
type ContentFolder = "articles" | "newsletter";
const getContent = ({ slug }: { readonly slug: ValidSlug }): `${ContentFolder}/${ValidSlug}` =>
`articles/${slug}`;
The example above is extremely strict, which means capturing all the assumptions with types to prevent any wrong combination of parameters.
Here is an example from the IndexedDB effect module I am working on. Can you spot the issue? π€
yield* api.transaction(
["todo"],
"readonly",
(api) =>
Effect.gen(function*() {
return yield* api.from("todo").insert({ id: 2, title: "test2", completed: false })
})
)
There is an hidden assumption when you specify "readonly"
: you can not insert
π
Turns how it's possible to prevent this issue on a type-level, making the API more type safe:
For the most courageous of you out there, here is the type to achieve this:
type Transaction = <
Source extends IndexedDbVersion.IndexedDbVersion.AnyWithProps,
Tables extends ReadonlyArray<
IndexedDbTable.IndexedDbTable.TableName<
IndexedDbVersion.IndexedDbVersion.Tables<Source>
>
>,
Mode extends "readonly" | "readwrite" = "readonly"
>(
tables: Tables & {
0: IndexedDbTable.IndexedDbTable.TableName<
IndexedDbVersion.IndexedDbVersion.Tables<Source>
>
},
mode: Mode,
callback: (api: {
readonly from: <A extends Tables[number]>(
table: A
) => Mode extends "readwrite" ? IndexedDbQueryBuilder.IndexedDbQueryBuilder.From<Source, A> :
Omit<
IndexedDbQueryBuilder.IndexedDbQueryBuilder.From<Source, A>,
"insert" | "insertAll" | "upsert" | "upsertAll" | "clear" | "delete"
>
}) => Effect.Effect<void>,
options?: globalThis.IDBTransactionOptions
) => Effect.Effect<void>
What if this goes too far?
From a user perspective, the more type safe the better ππΌββοΈ
But sometimes types become so strict that internally they don't match anymore.
That's why
as
has been created in TypeScript π
As long as the runtime behavior is coherent with the type, then you can mess up as much as you want in the internal implementation π
Simple example:
const makeProto = <Id extends string>(options: {
readonly identifier: Id
readonly version: number
readonly database: globalThis.IDBDatabase
readonly IDBKeyRange: typeof globalThis.IDBKeyRange
}): IndexedDbDatabase.Service<Id> => {
function IndexedDb() {}
Object.setPrototypeOf(IndexedDb, Proto)
IndexedDb.identifier = options.identifier
IndexedDb.version = options.version
IndexedDb.database = options.database
IndexedDb.IDBKeyRange = options.IDBKeyRange
return IndexedDb as any
}
This code returns an instance of IndexedDbDatabase
by attaching properties to an initially empty function
derived from a prototype, and then as any
to satisfy TypeScript π«‘
Weird, but it works, and it's inline with the definition of IndexedDbDatabase
ππΌββοΈ
Sometimes you as a developer know better than TypeScript π
"as any" is everywhere in library code
I first came around the merits of as
and as any
when contributing to xstate
. Example:
const restoredSnapshot = createMachineSnapshot(
{
...(snapshot as any),
children,
_nodes: Array.from(
getAllStateNodes(getStateNodes(this.root, (snapshot as any).value))
)
},
this
) as MachineSnapshot<
TContext,
TEvent,
TChildren,
TStateValue,
TTag,
TOutput,
TMeta,
TConfig
>
Types get so tangled, it's too much for TypeScript to keep prefect track. So you step in as developer and as any
when necessary.
The key in library code is to then implement unit tests to verify the correct behavior π
A beautiful type safe API, verified internally by strong and extensive tests
Since then, I became more lenient with the use of as
(only in library code) π
Another example from @typeonce/ecs:
export const Component = <Tag extends string>(
tag: Tag
): {
new <A extends Record<string, any> = {}>(
args: Equals<A, {}> extends true
? void
: {
readonly [P in keyof A as P extends "_tag" ? never : P]: A[P];
}
): { readonly _tag: Tag } & A;
readonly _tag: Tag;
} => {
class Base {
readonly _tag = tag;
constructor(args: any) {
if (args) {
Object.assign(this, args);
}
}
}
(Base.prototype as any).name = tag;
(Base as any)._tag = tag;
return Base as any;
};
The effect IndexedDB API is (nearly) done. It will follow a (somehow long) process of review and API adjustments before joining effect
(hopefully) π€
IndexedDB migrations simple and type-safe with @EffectTS_ π π Tables with Schema π Each version collects tables π Migrations auto-executed in sequence
After that I will become the first user of the module with a new app example, more news soon here of course π
See you next π