Anything to Declare?

How to write your own @types

About Me

  • Paul Lessing
  • Engineering Lead, Cisco ThousandEyes
  • Vue, Angular, Node.js, PHP, and others
  • github.com/paullessing
  • Medium: @P_Lessing

Overview

  • Writing @types for a third-party package
    • Basics of .d.ts files
    • Types and Values
    • Advanced Types & Patterns
  • Testing & Publishing
  • Questions

Getting Started

Web Push Notifications: Timely, Relevant, and Precise

https://developers.google.com/web/fundamentals/push-notifications/

Import the package

No types! 😭

What are these @types?

  • TypeScript definition files: .d.ts
    • Similar to C header files (.h)
  • Interface to JavaScript files
  • Type Checking

Differences between .ts and .d.ts

.ts

  • Explicity imported
  • Compiled to JavaScript
  • Contains code that is executed

.d.ts

  • Automatically imported ("Ambient")
    • Imported by name or via tsconfig.json
  • Contain information about other code files
  • Must export everything at top level
  • Still a .ts file: (almost) all the same rules apply

Why do I care?

  • Third-party Libraries
  • Intellisense
  • Legacy code
  • Migration
  • Publishing on npm
  • Applications for regular .ts

DefinitelyTyped

  • https://github.com/DefinitelyTyped/DefinitelyTyped
  • Library of types
  • Published to npm as @types/*
  • Open-source
    • Anyone can contribute

Let's get going on web-push

Package Overview

const webpush = require('web-push');

// Generate some keys
webpush
  .generateVAPIDKeys();

// Encrypt a payload
webpush
  .encrypt(userPublicKey, userAuth, payload, contentEncoding);

// Set config
webpush
  .setGCMAPIKey(apiKey);

// Generate headers
webpush
  .getVapidHeaders(audience, subject, publicKey, privateKey, contentEncoding, expiration);

// Generate Metadata
webpush
  .generateRequestDetails(pushSubscription, payload, options);

// Send a notification
webpush
  .sendNotification(pushSubscription, payload, options);

// Constants
webpush.supportedContentEncodings.AES_GCM
webpush.supportedContentEncodings.AES_128_GCM

You can auto-generate .d.ts files

Auto-Generated Code

  • tsc -d <file>.ts
  • Only from TypeScript files
  • Rough outline of code
  • Good starting point
    • Generate and tweak

Let's Read the Docs

Start with the easy ones

export interface VapidKeys {
  publicKey: string;
  privateKey: string;
}

export function generateVAPIDKeys(): VapidKeys;

Start with the easy ones, part 2

export function setGCMAPIKey(apiKey: string): void

Important: Read the Source

export function setGCMAPIKey(apiKey: string | null): void

Let's get more interesting

export function encrypt(
  userPublicKey: string,
  userAuth: string,
  payload: string,
  contentEncoding: string
): Promise<?>

Always check the source

export function encrypt(
  userPublicKey: string,
  userAuth: string,
  payload: string | Buffer,
  contentEncoding: string
): EncryptionResult
export interface EncryptionResult {
  localPublicKey: string;
  salt: string;
  cipherText: Buffer;
}

Declaring Constants

  • How to declare this?
export type ContentEncoding = 'aesgcm' | 'aes128gcm';
export const supportedContentEncodings: {
  readonly AES_GCM:     'aesgcm'    & ContentEncoding;
  readonly AES_128_GCM: 'aws128gcm' & ContentEncoding;
};
  • Note: Not actually a const!
  • 🤔 What does const mean inside .d.ts?

Types vs Values

Types vs Values

Types

  • Typescript only
  • Compiled Out

Values

  • Compiled to JavaScript
  • Can be on the right hand side of = in JS

Examples

Types

  • type T = number | string
  • interface I {}
  • import { T, I } from 'm'

Values

  • const foo = 'bar' // let, var
  • function hello()
  • namespace n { const x }
  • module m { const x }
  • import { v } from 'm'

Examples

Type -------v
  const me: User = { name: 'Paul Lessing' };
Value --^----------^
Type ----------------v-------------v------------v
function hash(value: Buffer, salt: string): md5.HashResult {}
Value ---^----^--------------^
??? ----------------------------------------^
  • What is md5?
    • Namespace!

Namespaces

  • Groups of types and/or values
  • Like objects containing entries
// File: moment.ts
namespace moment {
  export interface Duration {
    seconds: number;
  }
  export class Moment {
    static const version = '1.0';
  }
  export const HOURS_PER_DAY = 24;
}
  • const twoDays: moment.Duration = ... // Type
  • const now: moment.Moment = ... // Type
  • const hours = moment.HOURS_PER_DAY // Value
  • const version = moment.Moment.version // Value

Namespaces can be merged

  • Overlapping namespaces get merged
// File: @types/jasmine/index.d.ts
export namespace jasmine {
  export interface Matchers {
    toBeDefined(): boolean;
  }
}
// File: customElement.spec.ts
expect(element.class).toBeDefined();
expect(element).toHaveAttribute('inputValue', 17); // Custom expectation??
// File: src/types/jasmine.d.ts
export namespace jasmine {
  export interface Matchers {
    toHaveAttribute(expectedAttribute: string, expectedValue: any): boolean;
  }
}

Can something be Value and Type?

  • Class
    const p: Promise;
    p = new Promise();
    • Interface
    • Constructor function
  • Enum
    const bestDay: DaysOfWeek;
    bestDay = DaysOfWeek.SATURDAY;
    • Type
    • Enum object

Back to our code

export function encrypt(
  userPublicKey: string,
  userAuth: string,
  payload: string | Buffer,
  contentEncoding: ContentEncoding
): EncryptionResult;

export type ContentEncoding = 'aesgcm' | 'aes128gcm';

export const supportedContentEncodings: {
  readonly AES_GCM:     'aesgcm'    & ContentEncoding;
  readonly AES_128_GCM: 'aes128gcm' & ContentEncoding;
};
  • Better type safety for contentEncoding
  • Exported declaration for supportedContentEncodings

Complex Behaviours

Complex Behaviours

export interface PushSubscription {
  ...
}

export interface RequestOptions {
  ...
}

export interface Headers {
  [header: string]: string;
}

export interface RequestDetails {
  method: 'POST';
  headers: Headers;
  body: Buffer | null;
  endpoint: string;
  proxy?: string;
}


export function generateRequestDetails(
  subscription: PushSubscription,
  payload?: string | Buffer,
  options?: RequestOptions
): RequestDetails;

Complex Behaviours

  • How to model this behaviour?
  • Want to be as helpful (specific) as possible
  • Compile-time error handling

Overloading

  • One base method declaration
  • Multiple additional declarations without bodies
  • Compiler infers which one applies
function safeUppercase(value: string): string;
function safeUppercase(value: null): null;
function safeUppercase(value: string | null): string | null {
  if (typeof value === 'string') {
    return value.toUppercase();
  } else {
    return null;
  }
}
const x: string = safeToUppercase('hello'); // OK
const y: string = safeToUppercase(null); // Type 'null' is not assignable to type 'string'

Overloading generateRequestDetails

export function generateRequestDetails(subscription: PushSubscription,
  payload?: null,
  options?: RequestOptions
): RequestDetails & { body: null };
export function generateRequestDetails(subscription: PushSubscription,
  payload?: string | Buffer,
  options?: RequestOptions
): RequestDetails & { body: Buffer };
export function generateRequestDetails(subscription: PushSubscription,
  payload?: string | Buffer,
  options?: RequestOptions
): RequestDetails;

export interface RequestDetails {
  method: 'POST';
  headers: Headers;
  body: Buffer | null;
  endpoint: string;
  proxy?: string;
}

Advanced Types

Subtypes of string

  • Remember: 'some-string' is a type
  • Overloading based on specific inputs
  • Example: Event handling
function on(eventName: 'click', handler: (event: MouseEvent) => void);
function on(eventName: 'change', handler: (event: InputEvent) => void);
function on(eventName: string, handler: (event: Event) => void);

Implementing getVapidHeaders

Overloads for getVapidHeaders

export function getVapidHeaders(
  audience: string, subject: string, publicKey: string, privateKey: string,
  contentEncoding: 'aes128gcm', expiration?: number
): {
    Authorization: string;
};
export function getVapidHeaders(
  audience: string, subject: string, publicKey: string, privateKey: string,
  contentEncoding: 'aesgcm', expiration?: number
): {
    Authorization: string;
    'Crypto-Key': string;
};
export function getVapidHeaders(
  audience: string, subject: string, publicKey: string, privateKey: string,
  contentEncoding: ContentEncoding, expiration?: number
): {
    Authorization: string;
    'Crypto-Key'?: string;
};

Done! What now?

Testing

  • web-push-tests.ts
  • Not executed, but type checked
  • Tool: dtslint
  • $ExpectType
    • Ensure the return value is correct
  • @ts-expect-error
    • Ensure the assignment fails

Testing Example

// $ExpectType EncryptionResult
const encryptionResult = encrypt('publicKey', 'userAuth', 'myPayload',
        supportedContentEncodings.AES_GCM);

// $ExpectType string
encryptionResult.localPublicKey;
// $ExpectType string
encryptionResult.salt;
// $ExpectType Buffer
encryptionResult.cipherText;

declare const anything: any;

const buffer: Buffer = anything;
// $ExpectType EncryptionResult
encrypt('publicKey', 'userAuth', buffer,
        supportedContentEncodings.AES_128_GCM);

// Only valid encoding should be allowed
// @ts-expect-error
encrypt('publicKey', 'userAuth', 'myPayload', 'unknownEncoding');

Publishing

  • DefinitelyTyped
  • Pull Request
  • Published to @types/your-package
  • Usually around 1 week
  • Open-Source cred 🏅

Questions?

Further Reading

  • https://www.typescriptlang.org/docs/handbook
  • https://github.com/DefinitelyTyped/DefinitelyTyped
  • https://basarat.gitbooks.io/typescript